From e7ce4a918eebf9bcfa98c7eccecdd8d53520c5fc Mon Sep 17 00:00:00 2001 From: "Alex Yang (DevDiv)" Date: Wed, 22 Jul 2026 18:29:28 -0700 Subject: [PATCH] Remove local connector namespaces extension Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/plugin/marketplace.json | 6 - .../.github/plugin/plugin.json | 19 - extensions/connector-namespaces/LICENSE | 21 - extensions/connector-namespaces/README.md | 81 - extensions/connector-namespaces/armClient.mjs | 345 ---- .../assets/preview-connected.png | Bin 22914 -> 0 bytes .../connector-namespaces/assets/preview.png | Bin 32685 -> 0 bytes extensions/connector-namespaces/auth.mjs | 236 --- extensions/connector-namespaces/auth.test.mjs | 302 --- extensions/connector-namespaces/catalog.mjs | 70 - .../connector-namespaces/categories.mjs | 15 - .../copilot-extension.json | 4 - .../connector-namespaces/createPage.mjs | 403 ---- extensions/connector-namespaces/extension.mjs | 101 - extensions/connector-namespaces/install.mjs | 1007 ---------- .../install.reauth.test.mjs | 558 ------ .../connector-namespaces/install.test.mjs | 253 --- .../mcp-http-probe.test.mjs | 58 - extensions/connector-namespaces/package.json | 20 - .../connector-namespaces/preview/.gitignore | 2 - .../connector-namespaces/preview/README.md | 112 -- .../connector-namespaces/preview/fixtures.mjs | 117 -- .../connector-namespaces/preview/server.mjs | 151 -- .../connector-namespaces/preview/shots.mjs | 96 - extensions/connector-namespaces/renderer.mjs | 1762 ----------------- .../connector-namespaces/renderer.test.mjs | 447 ----- .../review-fixes.test.mjs | 104 - extensions/connector-namespaces/sandbox.mjs | 49 - .../connector-namespaces/sandbox.test.mjs | 54 - extensions/connector-namespaces/server.mjs | 656 ------ .../connector-namespaces/server.test.mjs | 281 --- extensions/connector-namespaces/state.mjs | 115 -- .../connector-namespaces/state.test.mjs | 32 - .../connector-namespaces/test/README.md | 135 -- .../connector-namespaces/test/mcp-probe.mjs | 173 -- .../connector-namespaces/test/safe-tools.mjs | 168 -- .../test/safe-tools.test.mjs | 150 -- .../connector-namespaces/test/smoke.mjs | 354 ---- 38 files changed, 8457 deletions(-) delete mode 100644 extensions/connector-namespaces/.github/plugin/plugin.json delete mode 100644 extensions/connector-namespaces/LICENSE delete mode 100644 extensions/connector-namespaces/README.md delete mode 100644 extensions/connector-namespaces/armClient.mjs delete mode 100644 extensions/connector-namespaces/assets/preview-connected.png delete mode 100644 extensions/connector-namespaces/assets/preview.png delete mode 100644 extensions/connector-namespaces/auth.mjs delete mode 100644 extensions/connector-namespaces/auth.test.mjs delete mode 100644 extensions/connector-namespaces/catalog.mjs delete mode 100644 extensions/connector-namespaces/categories.mjs delete mode 100644 extensions/connector-namespaces/copilot-extension.json delete mode 100644 extensions/connector-namespaces/createPage.mjs delete mode 100644 extensions/connector-namespaces/extension.mjs delete mode 100644 extensions/connector-namespaces/install.mjs delete mode 100644 extensions/connector-namespaces/install.reauth.test.mjs delete mode 100644 extensions/connector-namespaces/install.test.mjs delete mode 100644 extensions/connector-namespaces/mcp-http-probe.test.mjs delete mode 100644 extensions/connector-namespaces/package.json delete mode 100644 extensions/connector-namespaces/preview/.gitignore delete mode 100644 extensions/connector-namespaces/preview/README.md delete mode 100644 extensions/connector-namespaces/preview/fixtures.mjs delete mode 100644 extensions/connector-namespaces/preview/server.mjs delete mode 100644 extensions/connector-namespaces/preview/shots.mjs delete mode 100644 extensions/connector-namespaces/renderer.mjs delete mode 100644 extensions/connector-namespaces/renderer.test.mjs delete mode 100644 extensions/connector-namespaces/review-fixes.test.mjs delete mode 100644 extensions/connector-namespaces/sandbox.mjs delete mode 100644 extensions/connector-namespaces/sandbox.test.mjs delete mode 100644 extensions/connector-namespaces/server.mjs delete mode 100644 extensions/connector-namespaces/server.test.mjs delete mode 100644 extensions/connector-namespaces/state.mjs delete mode 100644 extensions/connector-namespaces/state.test.mjs delete mode 100644 extensions/connector-namespaces/test/README.md delete mode 100644 extensions/connector-namespaces/test/mcp-probe.mjs delete mode 100644 extensions/connector-namespaces/test/safe-tools.mjs delete mode 100644 extensions/connector-namespaces/test/safe-tools.test.mjs delete mode 100644 extensions/connector-namespaces/test/smoke.mjs diff --git a/.github/plugin/marketplace.json b/.github/plugin/marketplace.json index 15f6192bc..16f5a82f8 100644 --- a/.github/plugin/marketplace.json +++ b/.github/plugin/marketplace.json @@ -247,12 +247,6 @@ "description": "A visual orb that users can ask the agent to recolor while showing a live activity log in the canvas.", "version": "1.0.2" }, - { - "name": "connector-namespaces", - "source": "extensions/connector-namespaces", - "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.", - "version": "1.2.0" - }, { "name": "context-engineering", "source": "plugins/context-engineering", diff --git a/extensions/connector-namespaces/.github/plugin/plugin.json b/extensions/connector-namespaces/.github/plugin/plugin.json deleted file mode 100644 index e9fbc015f..000000000 --- a/extensions/connector-namespaces/.github/plugin/plugin.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "name": "connector-namespaces", - "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.", - "version": "1.2.0", - "author": { - "name": "Alex Yang", - "url": "https://github.com/alexyaang" - }, - "keywords": [ - "azure", - "connector-namespace", - "mcp", - "mcp-connectors", - "model-context-protocol", - "tool-discovery" - ], - "logo": "assets/preview.png", - "extensions": "." -} diff --git a/extensions/connector-namespaces/LICENSE b/extensions/connector-namespaces/LICENSE deleted file mode 100644 index 22aed37e6..000000000 --- a/extensions/connector-namespaces/LICENSE +++ /dev/null @@ -1,21 +0,0 @@ -MIT License - -Copyright (c) Microsoft Corporation. - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/extensions/connector-namespaces/README.md b/extensions/connector-namespaces/README.md deleted file mode 100644 index 66c534890..000000000 --- a/extensions/connector-namespaces/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# MCP Connectors - -A GitHub Copilot app canvas extension for discovering and connecting hosted MCP -servers from [Azure Connector Namespace](https://learn.microsoft.com/en-us/azure/connector-namespace/connector-namespace-overview). -It brings the Microsoft and partner connector catalog, guided browser sign-in, -and connected-server management into the Copilot side panel. - -## Features - -- **Connector catalog** - browse and search Microsoft and partner MCP servers - available in your namespace. -- **Guided Azure setup** - sign in from the canvas, then choose a subscription - and Connector Namespace. -- **Browser-based connection flow** - complete each connector's authentication - or consent without leaving the setup experience. -- **My MCPs** - see which servers are connected and ready to add to Copilot. -- **Namespace playground** - open any connected server in the Connector - Namespace playground with **Sandbox**. -- **Persistent namespace selection** - retain the selected namespace while Azure - tokens remain in memory and sign-in is requested again after a restart. - -## Install - -Open the GitHub Copilot app, go to **Settings > Plugins**, search for -`connector-namespaces`, and select **Install**. - -You can also open the -[MCP Connectors gallery page](https://awesome-copilot.github.com/extension/connector-namespaces/) -and select **Install in GitHub Copilot app**. - -## Requirements - -- Access to an Azure subscription with a Connector Namespace. If you do not - have one, follow the - [Connector Namespace creation guide](https://learn.microsoft.com/en-us/azure/connector-namespace/create-connector-namespace). -- Permission to view the namespace and create its connections and hosted MCP - server configurations. -- A browser for Microsoft Entra sign-in and connector consent. - -Connector Namespace is currently an Azure preview service and availability can -vary by region. - -## Usage - -1. Open the **MCP Connectors** canvas in the GitHub Copilot app. -2. Select **Sign in to Azure** and complete Microsoft Entra authentication in - your browser. -3. Choose an Azure subscription and Connector Namespace. -4. Browse or search the catalog, then select **Connect** on an MCP server. -5. Complete the connector's sign-in or consent flow when prompted. -6. Confirm the server appears under **My MCPs**. -7. Restart the GitHub Copilot app so the new tools become available to the - agent. - -Use **Sandbox** on a connected server to inspect it in the Connector Namespace -playground. Use **Change namespace** to switch subscriptions or namespaces. - -## Authentication and privacy - -Azure sign-in and connector sign-in are separate: - -- **Azure sign-in** lets the canvas discover and manage Connector Namespace - resources. Access and refresh tokens remain in the extension process and are - never written to extension files. Reloading the extension or restarting the - app requires Azure sign-in again. The selected namespace coordinates are - retained in - `~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json` so - the canvas can explain that the namespace is still linked and return directly - to its connectors after sign-in. -- **Connector sign-in** grants an individual MCP server access to its backing - service. The resulting connection is managed by Connector Namespace. - -The canvas serves its interface from loopback only (`127.0.0.1`). Azure -management requests are restricted to `https://management.azure.com/`. -The gateway API key that lets Copilot reach a connected server is stored in the -user-scoped GitHub Copilot MCP configuration and sent only to that server's -configured HTTPS endpoint. - -## License - -[MIT](./LICENSE) © Microsoft Corporation. diff --git a/extensions/connector-namespaces/armClient.mjs b/extensions/connector-namespaces/armClient.mjs deleted file mode 100644 index c4ef7f4a5..000000000 --- a/extensions/connector-namespaces/armClient.mjs +++ /dev/null @@ -1,345 +0,0 @@ -// ARM API client — fetches real connector data with interactive Azure credentials. - -import { constants as fsConstants, promises as fs } from "node:fs"; -import { platform } from "node:os"; -import { basename, isAbsolute, join, resolve, sep } from "node:path"; -import { getToken } from "./auth.mjs"; - -export { getToken }; - -const API_VERSION = "2026-05-01-preview"; -const RG_API_VERSION = "2021-04-01"; -const MSI_API_VERSION = "2023-01-31"; -const SUBS_API_VERSION = "2020-01-01"; - -function windowsSystemExecutable(name) { - const systemRoot = process.env.SystemRoot; - if (systemRoot && isAbsolute(systemRoot)) return join(systemRoot, "System32", name); - throw new Error(`Could not resolve the Windows system executable ${name}.`); -} - -async function trustedExecutablePath(path, expectedName, workspaceRoot = process.cwd()) { - if (!isAbsolute(path) || /["\r\n]/.test(path)) return null; - let candidate; - let workspace; - try { - [candidate, workspace] = await Promise.all([ - fs.realpath(path), - fs.realpath(workspaceRoot), - ]); - if (!(await fs.stat(candidate)).isFile()) return null; - if (platform() !== "win32") await fs.access(candidate, fsConstants.X_OK); - } catch { - return null; - } - const insensitive = platform() === "win32"; - const normalize = (value) => insensitive ? value.toLowerCase() : value; - const normalizedCandidate = normalize(resolve(candidate)); - const normalizedWorkspace = normalize(resolve(workspace)); - const workspacePrefix = normalizedWorkspace.endsWith(sep) - ? normalizedWorkspace - : `${normalizedWorkspace}${sep}`; - if ( - normalize(basename(candidate)) !== normalize(expectedName) || - normalizedCandidate === normalizedWorkspace || - normalizedCandidate.startsWith(workspacePrefix) - ) return null; - return candidate; -} - -export async function resolveSystemExecutable(name, workspaceRoot = process.cwd()) { - const candidates = platform() === "win32" - ? [windowsSystemExecutable(name)] - : [join("/usr/bin", name), join("/bin", name), join("/usr/local/bin", name)]; - for (const path of candidates) { - const candidate = await trustedExecutablePath(path, name, workspaceRoot); - if (candidate) return candidate; - } - throw new Error(`Could not resolve the trusted system executable ${name}.`); -} - -/** - * List all enabled Azure subscriptions the user has access to. - */ -// The set of enabled subscriptions is stable for a session, so cache it — the -// first /setup pays the ARM round-trip once and every "Change namespace" -// afterwards serves from memory. -let s_subsCache = null; // { subs, expiresAt } -const SUBS_TTL_MS = 30 * 60 * 1000; - -export function invalidateSubscriptionsCache() { - s_subsCache = null; -} - -export async function listSubscriptions({ forceRefresh = false } = {}) { - const now = Date.now(); - if (!forceRefresh && s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs; - const token = await getToken(); - const url = `https://management.azure.com/subscriptions?api-version=${SUBS_API_VERSION}`; - const raw = await paginateAll(url, token); - const subs = raw - .filter((s) => s.state === "Enabled") - .map((s) => ({ id: s.subscriptionId, name: s.displayName, tenantId: s.tenantId, state: s.state })); - s_subsCache = { subs, expiresAt: now + SUBS_TTL_MS }; - return subs; -} - -// ARM resource identifiers are a restricted charset (letters, digits and a few -// punctuation chars). Validating each path segment against this allowlist before -// it enters a URL rejects anything containing "/", "?", "#", "@" or ":" — the -// characters that could otherwise alter the request path or redirect the host — -// and acts as a taint barrier so config/file-derived names cannot reach fetch -// unvalidated. -const ARM_SEGMENT = /^[A-Za-z0-9._()-]{1,256}$/; - -export function armSegment(value) { - const s = String(value); - if (s === "." || s === ".." || !ARM_SEGMENT.test(s)) { - throw new Error(`Invalid ARM resource identifier: ${s}`); - } - return s; -} - -function buildBaseUrl(subscriptionId, resourceGroup, gatewayName) { - return `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourceGroups/${armSegment(resourceGroup)}/providers/Microsoft.Web/connectorGateways/${armSegment(gatewayName)}`; -} - -// Hard host allowlist: every request this client makes targets ARM and only -// ARM. The trailing slash matters — it blocks suffix/userinfo bypasses such as -// "https://management.azure.com.evil.com/" and "https://management.azure.com@evil.com/", -// neither of which starts with this exact prefix. This guards the paginated -// nextLink (a server-supplied value) which does not pass through armSegment. -const ARM_BASE = "https://management.azure.com/"; - -// Returns the URL only if it targets ARM, otherwise throws. Used by callers -// (e.g. install.mjs) that build ARM URLs before handing them here. -export function assertArmHost(rawUrl) { - const url = String(rawUrl); - if (!url.startsWith(ARM_BASE)) { - throw new Error(`Refusing to call non-ARM URL: ${url}`); - } - return url; -} - -async function armFetch(url, token) { - // Guard the exact value handed to fetch so a tainted path segment or a - // server-supplied nextLink can never redirect the call off ARM. - if (!url.startsWith(ARM_BASE)) { - throw new Error(`Refusing to call non-ARM URL: ${url}`); - } - const res = await fetch(url, { - headers: { - Authorization: `Bearer ${token}`, - "Content-Type": "application/json", - }, - }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`ARM ${res.status}: ${body.slice(0, 300)}`); - } - return res.json(); -} - -// ARM normally returns distinct nextLink URLs that terminate, but a buggy or -// hostile endpoint could return a repeating/self-referential nextLink. Guard -// against an unbounded loop with a seen-set and a hard page cap. -const MAX_PAGES = 1000; - -async function paginateAll(url, token) { - const results = []; - const seen = new Set(); - let nextUrl = url; - let pages = 0; - while (nextUrl) { - if (seen.has(nextUrl) || pages >= MAX_PAGES) break; - seen.add(nextUrl); - pages++; - const data = await armFetch(nextUrl, token); - if (data.value) results.push(...data.value); - nextUrl = data.nextLink || null; - } - return results; -} - -/** - * List connector gateways in a subscription. - * Uses $top=10 and stops after the first page for speed. - * Pass fetchAll=true to paginate through everything. - */ -export async function listConnectorGateways(subscriptionId, { fetchAll = false } = {}) { - const token = await getToken(); - const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/providers/Microsoft.Web/connectorGateways?api-version=${API_VERSION}&$top=10`; - if (fetchAll) return { items: await paginateAll(url, token), hasMore: false }; - // First page only — much faster - const data = await armFetch(url, token); - const items = data.value || []; - return { items, hasMore: !!data.nextLink }; -} - -/** - * List managed APIs (traditional connectors) - */ -export async function listManagedApis(subscriptionId, resourceGroup, gatewayName) { - const token = await getToken(); - const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedApis?api-version=${API_VERSION}`; - return paginateAll(url, token); -} - -/** - * List managed hosted MCP servers - */ -export async function listManagedHostedMcpServers(subscriptionId, resourceGroup, gatewayName) { - const token = await getToken(); - const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedHostedMcpServers?api-version=${API_VERSION}`; - return paginateAll(url, token); -} - -/** - * List managed MCP operations - */ -export async function listManagedMcpOperations(subscriptionId, resourceGroup, gatewayName) { - const token = await getToken(); - const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}/managedMcpOperations?api-version=${API_VERSION}`; - return paginateAll(url, token); -} - -// --------------------------------------------------------------------------- -// Create connector namespace (provisioning flow) -// --------------------------------------------------------------------------- - -const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); - -// Write helper (PUT/PATCH/DELETE) that mirrors armFetch's host guard but keeps -// the parsed error body so callers can surface ARM's message verbatim. -async function armWrite(method, url, body, extraHeaders = {}) { - if (!url.startsWith(ARM_BASE)) { - throw new Error(`Refusing to call non-ARM URL: ${url}`); - } - const token = await getToken(); - const headers = { Authorization: `Bearer ${token}` }; - Object.assign(headers, extraHeaders); - if (body !== undefined) headers["Content-Type"] = "application/json"; - const res = await fetch(url, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - const text = await res.text(); - let parsed; - try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = text; } - if (!res.ok) { - const msg = parsed?.error?.message ?? parsed?.message ?? text ?? `HTTP ${res.status}`; - const err = new Error(`ARM ${method} ${res.status}: ${String(msg).slice(0, 300)}`); - err.status = res.status; - throw err; - } - return parsed; -} - -/** - * List resource groups in a subscription (sorted by name). - */ -export async function listResourceGroups(subscriptionId) { - const token = await getToken(); - const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourcegroups?api-version=${RG_API_VERSION}`; - const items = await paginateAll(url, token); - return items - .map((rg) => ({ name: rg.name, location: rg.location })) - .sort((a, b) => a.name.localeCompare(b.name)); -} - -/** - * Create a resource group without updating an existing group on a name race. - */ -export async function createResourceGroup(subscriptionId, name, location) { - const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/resourcegroups/${armSegment(name)}?api-version=${RG_API_VERSION}`; - return armWrite("PUT", url, { location }, { "If-None-Match": "*" }); -} - -/** - * List user-assigned managed identities across a subscription (sorted by name). - */ -export async function listUserAssignedIdentities(subscriptionId) { - const token = await getToken(); - const url = `https://management.azure.com/subscriptions/${armSegment(subscriptionId)}/providers/Microsoft.ManagedIdentity/userAssignedIdentities?api-version=${MSI_API_VERSION}`; - const items = await paginateAll(url, token); - return items - .map((id) => { - const parts = String(id.id).split("/"); - const rgIdx = parts.findIndex((p) => p.toLowerCase() === "resourcegroups"); - return { - id: id.id, - name: id.name, - resourceGroup: rgIdx >= 0 ? parts[rgIdx + 1] || "" : "", - location: id.location || "", - }; - }) - .sort((a, b) => a.name.localeCompare(b.name)); -} - -/** - * Check whether a connector namespace name is free in the given resource group. - * Returns true when available (ARM 404), false when taken (200). Uses fetch - * directly so the 404 isn't thrown the way armFetch would. - */ -export async function checkConnectorGatewayNameAvailable(subscriptionId, resourceGroup, gatewayName) { - const token = await getToken(); - const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}?api-version=${API_VERSION}`; - const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } }); - if (res.status === 404) return true; - if (res.ok) return false; - const body = await res.text(); - throw new Error(`ARM ${res.status}: ${body.slice(0, 200)}`); -} - -// ARM `identity` block — mirrors the portal's buildIdentityPayload so the PUT -// body is always explicit ({ type: "None" } when nothing is configured). -export function buildGatewayIdentity(enableSystem, userAssignedIds = []) { - const hasUser = userAssignedIds.length > 0; - const type = enableSystem && hasUser - ? "SystemAssigned,UserAssigned" - : enableSystem - ? "SystemAssigned" - : hasUser - ? "UserAssigned" - : "None"; - const identity = { type }; - if (hasUser) { - identity.userAssignedIdentities = Object.fromEntries(userAssignedIds.map((id) => [id, {}])); - } - return identity; -} - -export async function waitForProvisioning(initialResult, gatewayName, fetchLatest, { - maxPolls = 60, - delay = () => sleep(3000), -} = {}) { - let result = initialResult; - let state; - for (let poll = 0; poll <= maxPolls; poll++) { - state = result?.properties?.provisioningState; - if (state === "Succeeded") return result; - if (state === "Failed" || state === "Canceled") { - throw new Error(`Provisioning ${state} for "${gatewayName}".`); - } - if (poll === maxPolls) break; - await delay(); - result = await fetchLatest(); - } - throw new Error(`Provisioning timed out for "${gatewayName}" (last state: ${state ?? "unknown"}).`); -} - -/** - * Create a connector namespace and poll until the - * provisioningState reaches a terminal value. Throws on Failed/Canceled. - * Returns the final resource object. - */ -export async function createConnectorGateway(subscriptionId, resourceGroup, gatewayName, { location, identity }) { - const token = await getToken(); - const url = `${buildBaseUrl(subscriptionId, resourceGroup, gatewayName)}?api-version=${API_VERSION}`; - const body = { location, properties: {}, identity }; - const result = await armWrite("PUT", url, body, { "If-None-Match": "*" }); - // ~3 min ceiling (60 * 3s). A 202 may have no body, so every state other - // than explicit Succeeded enters the polling path. - return waitForProvisioning(result, gatewayName, () => armFetch(url, token)); -} diff --git a/extensions/connector-namespaces/assets/preview-connected.png b/extensions/connector-namespaces/assets/preview-connected.png deleted file mode 100644 index 4050d8787eb92152429a831f3f0ca06176ff74d1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 22914 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 av{%Fg!nM3x7b6c`5wSp7n-n75@BRn&z>mZL diff --git a/extensions/connector-namespaces/assets/preview.png b/extensions/connector-namespaces/assets/preview.png deleted file mode 100644 index 259e27941361c56176347838b4c592b18bff62d3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32685 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 E11p{EHvj+t diff --git a/extensions/connector-namespaces/auth.mjs b/extensions/connector-namespaces/auth.mjs deleted file mode 100644 index c0eb5fd6a..000000000 --- a/extensions/connector-namespaces/auth.mjs +++ /dev/null @@ -1,236 +0,0 @@ -import { randomUUID } from "node:crypto"; -import { promises as fs } from "node:fs"; -import { homedir } from "node:os"; -import { join } from "node:path"; -import { InteractiveBrowserCredential } from "@azure/identity"; - -export const ARM_SCOPE = "https://management.azure.com/.default"; - -const TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000; -const SIGN_IN_SESSION_TTL_MS = 10 * 60 * 1000; -const AUTH_STORAGE_DIR = join( - process.env.COPILOT_HOME || join(homedir(), ".copilot"), - "extensions", - "connector-namespaces", - "artifacts", -); -const AUTH_RECORD_FILE = join(AUTH_STORAGE_DIR, "azure-auth-record.json"); -const LEGACY_AUTH_CACHE = join(AUTH_STORAGE_DIR, "auth-cache.json"); - -let legacyAuthArtifactsRemoved = false; - -async function removeLegacyAuthArtifacts() { - if (legacyAuthArtifactsRemoved) return; - for (const path of [AUTH_RECORD_FILE, LEGACY_AUTH_CACHE]) { - try { - await fs.unlink(path); - } catch (error) { - if (error?.code !== "ENOENT") { - throw new Error(`Could not remove the legacy connector authentication file at ${path}: ${error.message}`); - } - } - } - legacyAuthArtifactsRemoved = true; -} - -export class ConnectorAuthenticationRequiredError extends Error { - constructor(message = "Sign in to Azure to continue.", options) { - super(message, options); - this.name = "ConnectorAuthenticationRequiredError"; - this.code = "authentication_required"; - } -} - -export function isAuthenticationRequiredError(error) { - return error instanceof ConnectorAuthenticationRequiredError - || error?.code === "authentication_required" - || error?.name === "AuthenticationRequiredError"; -} - -function credentialFactory(options) { - return new InteractiveBrowserCredential(options); -} - -function hasUsableToken(accessToken, now) { - return !!accessToken?.token - && Number.isFinite(accessToken.expiresOnTimestamp) - && accessToken.expiresOnTimestamp - TOKEN_EXPIRY_SKEW_MS > now; -} - -function errorDetail(error) { - return String(error?.message || error || "Azure sign-in failed.").slice(0, 400); -} - -export class InteractiveAuthBroker { - constructor({ - createCredential = credentialFactory, - createSessionId = randomUUID, - cleanupLegacyCredentials = async () => {}, - now = Date.now, - scope = ARM_SCOPE, - } = {}) { - this.createCredential = createCredential; - this.createSessionId = createSessionId; - this.cleanupLegacyCredentials = cleanupLegacyCredentials; - this.now = now; - this.scope = scope; - this.credential = null; - this.accessToken = null; - this.cleanupInFlight = null; - this.tokenInFlight = null; - this.sessions = new Map(); - } - - createInteractiveCredential() { - return this.createCredential({ - redirectUri: "http://localhost", - disableAutomaticAuthentication: true, - }); - } - - ensureLegacyCredentialsRemoved() { - if (!this.cleanupInFlight) { - const cleanup = Promise.resolve() - .then(() => this.cleanupLegacyCredentials()) - .catch((error) => { - if (this.cleanupInFlight === cleanup) this.cleanupInFlight = null; - throw error; - }); - this.cleanupInFlight = cleanup; - } - return this.cleanupInFlight; - } - - pruneSessions() { - const cutoff = this.now() - SIGN_IN_SESSION_TTL_MS; - for (const [sessionId, session] of this.sessions) { - if (session.createdAt >= cutoff) continue; - session.status = "cancelled"; - session.abortController.abort(); - this.sessions.delete(sessionId); - } - } - - startSignIn() { - this.pruneSessions(); - const sessionId = this.createSessionId(); - const abortController = new AbortController(); - let credential; - try { - credential = this.createInteractiveCredential(); - } catch (error) { - return { ok: false, reason: "identity_unavailable", error: errorDetail(error) }; - } - - const session = { - abortController, - createdAt: this.now(), - error: "", - status: "pending", - }; - this.sessions.set(sessionId, session); - - session.promise = Promise.resolve() - .then(async () => { - await this.ensureLegacyCredentialsRemoved(); - const authenticationRecord = await credential.authenticate( - this.scope, - { abortSignal: abortController.signal }, - ); - if (!authenticationRecord) { - throw new Error("Azure identity did not return an authentication record."); - } - const accessToken = await credential.getToken(this.scope, { abortSignal: abortController.signal }); - if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) { - throw new Error("Azure identity returned an incomplete ARM access token."); - } - if (session.status !== "pending" || this.sessions.get(sessionId) !== session) return; - this.credential = credential; - this.accessToken = accessToken; - session.status = "done"; - }) - .catch((error) => { - if (session.status !== "pending") return; - session.status = abortController.signal.aborted ? "cancelled" : "error"; - if (session.status === "error") session.error = errorDetail(error); - }); - - return { ok: true, sessionId, mode: "interactive" }; - } - - getSignInStatus(sessionId) { - this.pruneSessions(); - const session = this.sessions.get(sessionId); - if (!session) return { ok: false, status: "unknown" }; - if (session.status === "pending") return { ok: true, status: "pending", mode: "interactive" }; - - if (session.status === "done") return { ok: true, status: "done" }; - if (session.status === "cancelled") return { ok: true, status: "cancelled" }; - return { ok: false, status: "error", error: session.error || "Azure sign-in failed." }; - } - - cancelSignIn(sessionId) { - const session = this.sessions.get(sessionId); - if (!session || session.status !== "pending") return { ok: true }; - session.status = "cancelled"; - session.abortController.abort(); - return { ok: true }; - } - - async getToken() { - await this.ensureLegacyCredentialsRemoved(); - if (hasUsableToken(this.accessToken, this.now())) return this.accessToken.token; - if (this.tokenInFlight) return this.tokenInFlight; - - const request = (async () => { - let credential = this.credential; - if (!credential) { - try { - credential = this.createInteractiveCredential(); - this.credential = credential; - } catch (error) { - if (isAuthenticationRequiredError(error)) { - throw new ConnectorAuthenticationRequiredError( - "Sign in to Azure to continue.", - { cause: error }, - ); - } - throw error; - } - } - try { - const accessToken = await credential.getToken(this.scope); - if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) { - throw new Error("Azure identity returned an incomplete ARM access token."); - } - this.accessToken = accessToken; - return accessToken.token; - } catch (error) { - if (!isAuthenticationRequiredError(error)) throw error; - if (this.credential === credential) { - this.credential = null; - this.accessToken = null; - } - throw new ConnectorAuthenticationRequiredError( - "Sign in to Azure to continue.", - { cause: error }, - ); - } - })(); - this.tokenInFlight = request; - try { - return await request; - } finally { - if (this.tokenInFlight === request) this.tokenInFlight = null; - } - } -} - -export const interactiveAuth = new InteractiveAuthBroker({ - cleanupLegacyCredentials: removeLegacyAuthArtifacts, -}); - -export const startSignIn = () => interactiveAuth.startSignIn(); -export const getSignInStatus = (sessionId) => interactiveAuth.getSignInStatus(sessionId); -export const cancelSignIn = (sessionId) => interactiveAuth.cancelSignIn(sessionId); -export const getToken = () => interactiveAuth.getToken(); diff --git a/extensions/connector-namespaces/auth.test.mjs b/extensions/connector-namespaces/auth.test.mjs deleted file mode 100644 index 332786465..000000000 --- a/extensions/connector-namespaces/auth.test.mjs +++ /dev/null @@ -1,302 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; - -import { - ConnectorAuthenticationRequiredError, - InteractiveAuthBroker, -} from "./auth.mjs"; - -function accessToken(token = "token", expiresOnTimestamp = 2_000_000_000_000) { - return { token, expiresOnTimestamp }; -} - -function authenticationRecord(username = "user@example.com") { - return { - authority: "login.microsoftonline.com", - homeAccountId: "home-account", - clientId: "client-id", - tenantId: "tenant-id", - username, - }; -} - -test("ARM token requests require an explicit browser sign-in", async () => { - let credentialOptions; - const broker = new InteractiveAuthBroker({ - createCredential(options) { - credentialOptions = options; - return { - async getToken() { - const error = new Error("No cached account found."); - error.name = "AuthenticationRequiredError"; - throw error; - }, - }; - }, - }); - - await assert.rejects( - broker.getToken(), - (error) => error instanceof ConnectorAuthenticationRequiredError - && error.code === "authentication_required", - ); - assert.deepEqual(credentialOptions, { - redirectUri: "http://localhost", - disableAutomaticAuthentication: true, - }); -}); - -test("interactive sign-in reports pending then done and keeps the ARM token in memory", async () => { - let credentialOptions; - let authenticateOptions; - const credential = { - async authenticate(scope, options) { - assert.equal(scope, "https://management.azure.com/.default"); - authenticateOptions = options; - return authenticationRecord(); - }, - async getToken(scope, options) { - assert.equal(scope, "https://management.azure.com/.default"); - assert.equal(options.abortSignal, authenticateOptions.abortSignal); - return accessToken(); - }, - }; - const broker = new InteractiveAuthBroker({ - createCredential(options) { - credentialOptions = options; - return credential; - }, - createSessionId: () => "signin-session", - now: () => 1_000, - }); - - const started = broker.startSignIn(); - assert.deepEqual(started, { - ok: true, - sessionId: "signin-session", - mode: "interactive", - }); - assert.deepEqual(broker.getSignInStatus(started.sessionId), { - ok: true, - status: "pending", - mode: "interactive", - }); - - await broker.sessions.get(started.sessionId).promise; - - assert.deepEqual(credentialOptions, { - redirectUri: "http://localhost", - disableAutomaticAuthentication: true, - }); - assert.equal(authenticateOptions.abortSignal.aborted, false); - assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" }); - assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" }); - assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true }); - assert.equal(authenticateOptions.abortSignal.aborted, false); - assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" }); - assert.equal(await broker.getToken(), "token"); -}); - -test("a new broker requires browser sign-in after the extension reloads", async () => { - let authenticateCalls = 0; - let createCredentialCalls = 0; - const createCredential = (options) => { - createCredentialCalls++; - assert.deepEqual(options, { - redirectUri: "http://localhost", - disableAutomaticAuthentication: true, - }); - let signedIn = false; - return { - async authenticate() { - authenticateCalls++; - signedIn = true; - return authenticationRecord(); - }, - async getToken() { - if (signedIn) return accessToken("memory-token"); - const error = new Error("No cached account found."); - error.name = "AuthenticationRequiredError"; - throw error; - }, - }; - }; - const signedInBroker = new InteractiveAuthBroker({ - createCredential, - createSessionId: () => "persist-session", - now: () => 1_000, - }); - const started = signedInBroker.startSignIn(); - await signedInBroker.sessions.get(started.sessionId).promise; - assert.equal(authenticateCalls, 1); - assert.equal(await signedInBroker.getToken(), "memory-token"); - - const restartedBroker = new InteractiveAuthBroker({ - createCredential, - now: () => 1_000, - }); - await assert.rejects(restartedBroker.getToken(), ConnectorAuthenticationRequiredError); - assert.equal(authenticateCalls, 1); - assert.equal(createCredentialCalls, 2); -}); - -test("concurrent first-time token requests share credential acquisition", async () => { - let releaseToken; - const tokenReady = new Promise((resolve) => { - releaseToken = resolve; - }); - let createCredentialCalls = 0; - let tokenCalls = 0; - const broker = new InteractiveAuthBroker({ - createCredential: () => { - createCredentialCalls++; - return { - async getToken() { - tokenCalls++; - await tokenReady; - return accessToken("shared-token"); - }, - }; - }, - }); - - const firstRequest = broker.getToken(); - const secondRequest = broker.getToken(); - await new Promise((resolve) => setImmediate(resolve)); - releaseToken(); - - assert.deepEqual( - await Promise.all([firstRequest, secondRequest]), - ["shared-token", "shared-token"], - ); - assert.equal(createCredentialCalls, 1); - assert.equal(tokenCalls, 1); -}); - -test("cancelling sign-in aborts the credential request", async () => { - let abortSignal; - const credential = { - authenticate(_scope, options) { - abortSignal = options.abortSignal; - return new Promise((resolve, reject) => { - options.abortSignal.addEventListener("abort", () => reject(new Error("aborted")), { once: true }); - }); - }, - async getToken() { - const error = new Error("No cached account found."); - error.name = "AuthenticationRequiredError"; - throw error; - }, - }; - const broker = new InteractiveAuthBroker({ - createCredential: () => credential, - createSessionId: () => "cancel-session", - now: () => 1_000, - }); - - const started = broker.startSignIn(); - const pending = broker.sessions.get(started.sessionId).promise; - await new Promise((resolve) => setImmediate(resolve)); - assert.equal(abortSignal.aborted, false); - - assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true }); - await pending; - - assert.equal(abortSignal.aborted, true); - assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "cancelled" }); - assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "cancelled" }); - await assert.rejects(broker.getToken(), ConnectorAuthenticationRequiredError); -}); - -test("sign-in failures are surfaced through the status endpoint contract", async () => { - const broker = new InteractiveAuthBroker({ - createCredential: () => ({ - async authenticate() { - throw new Error("browser launch failed"); - }, - async getToken() { - throw new Error("unreachable"); - }, - }), - createSessionId: () => "failed-session", - now: () => 1_000, - }); - - const started = broker.startSignIn(); - await broker.sessions.get(started.sessionId).promise; - - assert.deepEqual(broker.getSignInStatus(started.sessionId), { - ok: false, - status: "error", - error: "browser launch failed", - }); - assert.deepEqual(broker.getSignInStatus(started.sessionId), { - ok: false, - status: "error", - error: "browser launch failed", - }); - assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true }); - assert.deepEqual(broker.getSignInStatus(started.sessionId), { - ok: false, - status: "error", - error: "browser launch failed", - }); -}); - -test("legacy credential cleanup retries after a transient failure", async () => { - let cleanupCalls = 0; - const broker = new InteractiveAuthBroker({ - cleanupLegacyCredentials: async () => { - cleanupCalls++; - if (cleanupCalls === 1) throw new Error("legacy cache is locked"); - }, - createCredential: () => ({ - async getToken() { - return accessToken(); - }, - }), - }); - - await assert.rejects(broker.getToken(), /legacy cache is locked/); - assert.equal(await broker.getToken(), "token"); - assert.equal(cleanupCalls, 2); -}); - -test("token acquisition preserves operational errors and retries the credential", async () => { - const outage = new Error("Azure Identity network request timed out"); - let createCredentialCalls = 0; - let tokenCalls = 0; - const broker = new InteractiveAuthBroker({ - createCredential: () => { - createCredentialCalls++; - return { - async getToken() { - tokenCalls++; - if (tokenCalls === 1) throw outage; - return accessToken(); - }, - }; - }, - }); - - await assert.rejects(broker.getToken(), (error) => error === outage); - assert.equal(await broker.getToken(), "token"); - assert.equal(createCredentialCalls, 1); - assert.equal(tokenCalls, 2); -}); - -test("incomplete tokens remain operational errors instead of prompting sign-in", async () => { - const broker = new InteractiveAuthBroker({ - createCredential: () => ({ - async getToken() { - return { token: "incomplete" }; - }, - }), - }); - - await assert.rejects( - broker.getToken(), - (error) => !(error instanceof ConnectorAuthenticationRequiredError) - && error.message === "Azure identity returned an incomplete ARM access token.", - ); -}); diff --git a/extensions/connector-namespaces/catalog.mjs b/extensions/connector-namespaces/catalog.mjs deleted file mode 100644 index 79f4165cb..000000000 --- a/extensions/connector-namespaces/catalog.mjs +++ /dev/null @@ -1,70 +0,0 @@ -// Catalog — fetches MCP connectors from the gateway. -// -// The gateway exposes ~1600 managed APIs (the full Logic Apps connector -// catalog). MCP servers are a small subset (~43) and there is no `kind` or -// capability flag that distinguishes them. The only reliable signal is the -// string "mcp" appearing in the API's name OR its display name — and those are -// genuinely independent signals: `workiqsharepoint` has no "mcp" in its name -// (display name "Work IQ SharePoint MCP"), while `hginsightsmcp` has "mcp" in -// its name but a display name of "HG Insights Connect". Matching either keeps -// the full set without an allowlist that has to be hand-maintained. - -import { listManagedApis } from "./armClient.mjs"; -import { CATEGORY } from "./categories.mjs"; - -function isMcpServer(api) { - const name = api.name || ""; - const displayName = api.properties?.generalInformation?.displayName || ""; - return /mcp/i.test(name) || /mcp/i.test(displayName); -} - -// Microsoft first-party servers (a365*/d365*/workiq* names, or a Microsoft- -// branded display name) group under "Microsoft"; everything else is a partner -// server. Derived rather than hardcoded so new servers categorize themselves. -function categoryFor(name, displayName) { - const n = (name || "").toLowerCase(); - const d = (displayName || "").toLowerCase(); - const isMicrosoft = - /^(a365|d365|workiq)/.test(n) || - d.startsWith("microsoft") || - d.startsWith("work iq") || - d.startsWith("dynamics 365"); - return isMicrosoft ? CATEGORY.microsoft : CATEGORY.partner; -} - -let cachedCatalog = null; -let cacheKey = null; - -export function invalidateCache() { - cachedCatalog = null; - cacheKey = null; -} - -export async function fetchCatalog(subscriptionId, resourceGroup, gatewayName) { - const key = `${subscriptionId}/${resourceGroup}/${gatewayName}`; - if (cachedCatalog && cacheKey === key) return cachedCatalog; - - const apis = await listManagedApis(subscriptionId, resourceGroup, gatewayName); - - const catalog = apis - .filter(isMcpServer) - .map((a) => { - const props = a.properties || {}; - const general = props.generalInformation || {}; - const metadata = props.metadata || {}; - const displayName = general.displayName || a.name; - return { - id: a.name, - apiName: a.name, - displayName, - description: general.description || "", - iconUri: general.iconUri || "", - brandColor: metadata.brandColor || "", - category: categoryFor(a.name, displayName), - }; - }); - - cachedCatalog = catalog; - cacheKey = key; - return catalog; -} diff --git a/extensions/connector-namespaces/categories.mjs b/extensions/connector-namespaces/categories.mjs deleted file mode 100644 index 57cd9592f..000000000 --- a/extensions/connector-namespaces/categories.mjs +++ /dev/null @@ -1,15 +0,0 @@ -// Catalog category values. -// -// `category` is a routing key, not display text: the renderer partitions catalog -// items into the Microsoft vs Partners sections by comparing against these exact -// values (see renderCatalogHtml in renderer.mjs). Kept as a frozen enum here, -// rather than free-form strings scattered across the producer, renderer, and -// tests, so the routing contract lives in one place. -// -// Zero-dependency on purpose: renderer.mjs imports this, and renderer.test.mjs -// loads renderer.mjs as a pure string-rendering gate. Sourcing the enum from -// catalog.mjs instead would drag armClient.mjs (the ARM SDK) into that gate. -export const CATEGORY = Object.freeze({ - microsoft: "Microsoft", - partner: "Partners", -}); diff --git a/extensions/connector-namespaces/copilot-extension.json b/extensions/connector-namespaces/copilot-extension.json deleted file mode 100644 index 3949dbe48..000000000 --- a/extensions/connector-namespaces/copilot-extension.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "name": "connector-namespaces", - "version": 1 -} diff --git a/extensions/connector-namespaces/createPage.mjs b/extensions/connector-namespaces/createPage.mjs deleted file mode 100644 index 3eb866592..000000000 --- a/extensions/connector-namespaces/createPage.mjs +++ /dev/null @@ -1,403 +0,0 @@ -// Renderer for the "Create connector namespace" wizard page. Mirrors the -// portal's CreateConnectorGatewayPage: subscription -> resource group -// (existing or new) -> region -> name (live availability) -> managed identity -// (system + user-assigned) -> real ARM provisioning. - -import { baseStyles, brandMark } from "./renderer.mjs"; - -function esc(s) { - return String(s || "").replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); -} - -// Connector namespace regions — kept in sync with the portal's -// CONNECTOR_NAMESPACE_REGIONS list (constants.ts). -const REGIONS = [ - ["australiaeast", "Australia East"], ["brazilsouth", "Brazil South"], - ["canadacentral", "Canada Central"], ["canadaeast", "Canada East"], - ["centralindia", "Central India"], ["centralus", "Central US"], - ["eastasia", "East Asia"], ["eastus", "East US"], ["eastus2", "East US 2"], - ["francecentral", "France Central"], ["germanywestcentral", "Germany West Central"], - ["italynorth", "Italy North"], ["japaneast", "Japan East"], - ["koreacentral", "Korea Central"], ["northcentralus", "North Central US"], - ["northeurope", "North Europe"], ["norwayeast", "Norway East"], - ["polandcentral", "Poland Central"], ["southafricanorth", "South Africa North"], - ["southcentralus", "South Central US"], ["southindia", "South India"], - ["southeastasia", "Southeast Asia"], ["spaincentral", "Spain Central"], - ["swedencentral", "Sweden Central"], ["switzerlandnorth", "Switzerland North"], - ["uaenorth", "UAE North"], ["uksouth", "UK South"], - ["westcentralus", "West Central US"], ["westus2", "West US 2"], - ["westus3", "West US 3"], -]; - -const DEFAULT_REGION = "eastus"; - -export function renderCreateNamespaceHtml(subscriptions, preselectedSub = "", capabilityToken = "") { - const subOptions = subscriptions.map((s) => - `` - ).join(""); - - const regionOptions = REGIONS.map(([v, l]) => - `` - ).join(""); - - return ` - -Create Connector Namespace${baseStyles()} - - -
-

${brandMark(28, "create")}Create connector namespace

-
Provisions a real Azure connector namespace (Microsoft.Web/connectorGateways) in your subscription.
-
- -
- - -
- -
- -
- - -
- - -
Pick the resource group the namespace will live in.
-
- -
- - -
- -
- - -
-
- -
- -
- - -
-
- - -
- -
- -
- - -
- -
- -`; -} diff --git a/extensions/connector-namespaces/extension.mjs b/extensions/connector-namespaces/extension.mjs deleted file mode 100644 index 94025ee71..000000000 --- a/extensions/connector-namespaces/extension.mjs +++ /dev/null @@ -1,101 +0,0 @@ -// Canvas extension entry point — MCP Connectors browser. - -import { joinSession, createCanvas } from "@github/copilot-sdk/extension"; -import { getServerConfig, startServer, stopServer } from "./server.mjs"; -import { getSavedConfig, loadSavedConfig, saveConfig } from "./state.mjs"; -import { fetchCatalog } from "./catalog.mjs"; -import { getInstalledState, openInBrowser, setWorkspaceRoot } from "./install.mjs"; -import { buildSandboxUrl, resolveSandboxConnector } from "./sandbox.mjs"; - -// Load any previously saved connector namespace config on startup -loadSavedConfig(); - -async function openPlayground(server, instanceId) { - const config = instanceId ? getServerConfig(instanceId) : getSavedConfig(); - if (!config) return { opened: false, reason: "no_namespace_configured" }; - const catalog = await fetchCatalog(config.subscriptionId, config.resourceGroup, config.gatewayName); - const installedState = await getInstalledState(config); - const resolved = resolveSandboxConnector(catalog, installedState, server); - if (!resolved.connector) return { opened: false, ...resolved }; - const url = buildSandboxUrl(config, resolved.connector.id); - await openInBrowser(url); - return { opened: true, server: resolved.connector, url }; -} - -const session = await joinSession({ - tools: [ - { - name: "connector_namespaces_open_playground", - description: "Open a named connector from My MCPs in the Azure Connector Namespace playground.", - parameters: { - type: "object", - properties: { - server: { - type: "string", - description: "Connector display name or server ID from My MCPs", - }, - }, - required: ["server"], - }, - handler: async ({ server }) => JSON.stringify(await openPlayground(server)), - }, - ], - canvases: [ - createCanvas({ - id: "connector-namespaces", - displayName: "MCP Connectors", - description: "Discover, connect, and manage hosted MCP servers from Azure Connector Namespace.", - inputSchema: { - type: "object", - properties: { - subscriptionId: { type: "string", description: "Azure subscription ID (optional \u2014 if omitted, uses saved config or shows picker)" }, - resourceGroup: { type: "string", description: "Resource group name" }, - gatewayName: { type: "string", description: "Connector namespace name" }, - }, - }, - actions: [ - { - name: "open_sandbox", - description: "Open a named connector from My MCPs in the Azure Connector Namespace Sandbox", - inputSchema: { - type: "object", - properties: { - server: { - type: "string", - description: "Connector display name or server ID from My MCPs", - }, - }, - required: ["server"], - }, - handler: async (ctx) => openPlayground(ctx.input.server, ctx.instanceId), - }, - ], - open: async (ctx) => { - let config; - // If explicit input provided, use it and save for future - if (ctx.input && ctx.input.subscriptionId && ctx.input.resourceGroup && ctx.input.gatewayName) { - config = { - subscriptionId: ctx.input.subscriptionId, - resourceGroup: ctx.input.resourceGroup, - gatewayName: ctx.input.gatewayName, - }; - saveConfig(config); - } - // A saved config seeds a new panel only. Rehydrating an existing - // panel keeps its active namespace even if another panel changed - // the persisted default. - const entry = await startServer( - ctx.instanceId, - config ? { config } : { defaultConfig: getSavedConfig() }, - ); - return { title: "MCP Connectors", url: entry.url }; - }, - onClose: async (ctx) => { - await stopServer(ctx.instanceId); - }, - }), - ], -}); - -// Tell the install pipeline where the workspace .mcp.json lives (if any). -setWorkspaceRoot(session.workspacePath); diff --git a/extensions/connector-namespaces/install.mjs b/extensions/connector-namespaces/install.mjs deleted file mode 100644 index 74264d6fb..000000000 --- a/extensions/connector-namespaces/install.mjs +++ /dev/null @@ -1,1007 +0,0 @@ -// Install flow — creates connection, handles OAuth, creates MCP server config, -// mints API key, and writes to ~/.copilot/mcp-config.json. - -import { promises as fs } from "node:fs"; -import { spawn } from "node:child_process"; -import { randomBytes } from "node:crypto"; -import { homedir, platform } from "node:os"; -import { dirname, join } from "node:path"; -import { getToken, assertArmHost, armSegment, resolveSystemExecutable } from "./armClient.mjs"; - -const COPILOT_HOME = process.env.COPILOT_HOME || join(homedir(), ".copilot"); - -// Two scopes the Copilot CLI reads MCP servers from: -// profile -> ~/.copilot/mcp-config.json (private, follows you everywhere) -// workspace -> /.mcp.json (shared with the repo, git-tracked) -const PROFILE_MCP_PATH = join(COPILOT_HOME, "mcp-config.json"); -const PENDING_CLEANUP_DIR = join(COPILOT_HOME, "extensions", "connector-namespaces", "artifacts", "pending-cleanup"); -let s_workspaceRoot = null; - -export function setWorkspaceRoot(path) { - s_workspaceRoot = path || null; -} - -export function getWorkspaceRoot() { - return s_workspaceRoot; -} - -function mcpConfigPath(scope) { - if (scope === "workspace") { - if (!s_workspaceRoot) throw new Error("No workspace folder is available for this session."); - return join(s_workspaceRoot, ".mcp.json"); - } - return PROFILE_MCP_PATH; -} - -const CONFIG_LOCK_TIMEOUT_MS = 10_000; -const CONFIG_LOCK_STALE_MS = 30_000; - -// Serialize in-process writes, then hold an exclusive lock file so separate -// Copilot sessions cannot overwrite each other's MCP config changes. -let s_configLock = Promise.resolve(); -async function acquireConfigLock(path) { - const lockPath = `${path}.lock`; - await fs.mkdir(dirname(path), { recursive: true, mode: 0o700 }); - const deadline = Date.now() + CONFIG_LOCK_TIMEOUT_MS; - for (;;) { - const owner = `${process.pid}:${randomBytes(12).toString("hex")}\n`; - try { - const handle = await fs.open(lockPath, "wx", 0o600); - await handle.writeFile(owner, "utf8"); - return async () => { - await handle.close(); - try { - if (await fs.readFile(lockPath, "utf8") === owner) { - await fs.unlink(lockPath); - } - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - }; - } catch (error) { - if (error?.code !== "EEXIST") throw error; - try { - const stat = await fs.stat(lockPath); - if (Date.now() - stat.mtimeMs > CONFIG_LOCK_STALE_MS) { - const stalePath = `${lockPath}.${process.pid}.${randomBytes(6).toString("hex")}.stale`; - await fs.rename(lockPath, stalePath); - await fs.unlink(stalePath); - continue; - } - } catch (statError) { - if (statError?.code === "ENOENT") continue; - throw statError; - } - if (Date.now() >= deadline) { - throw new Error(`Timed out waiting for the MCP config lock at ${lockPath}.`); - } - await sleep(50); - } - } -} - -export async function waitForConnected(config, connName, options = {}) { - const maxPolls = options.maxPolls ?? 20; - const delay = options.delay ?? sleep; - const getStatus = options.getStatus ?? getConnectionStatus; - let status = "Unknown"; - for (let i = 0; i < maxPolls; i++) { - status = await getStatus(config, connName); - if (status === "Connected") return status; - if (i + 1 < maxPolls) await delay(1000); - } - throw new Error(`Connection ended in state "${status}".`); -} - -function withConfigLock(path, fn) { - const run = s_configLock.then(async () => { - const release = await acquireConfigLock(path); - try { - return await fn(); - } finally { - await release(); - } - }, async () => { - const release = await acquireConfigLock(path); - try { - return await fn(); - } finally { - await release(); - } - }); - s_configLock = run.then(() => {}, () => {}); - return run; -} - -async function readPendingCleanups(gateway, apiName) { - let names; - try { - names = await fs.readdir(PENDING_CLEANUP_DIR); - } catch (error) { - if (error?.code === "ENOENT") return []; - throw error; - } - - const records = []; - for (const name of names) { - if (!name.endsWith(".json")) continue; - const path = join(PENDING_CLEANUP_DIR, name); - let record; - try { - record = JSON.parse(await fs.readFile(path, "utf8")); - } catch (error) { - if (error?.code === "ENOENT") continue; - throw error; - } - if (!record || typeof record !== "object" || Array.isArray(record)) { - throw new Error("Pending connector cleanup data is invalid."); - } - if (record.gatewayId === gateway && record.apiName === apiName) { - records.push({ ...record, path }); - } - } - return records; -} - -async function getPendingCleanup(gateway, apiName) { - const records = await readPendingCleanups(gateway, apiName); - if (!records.length) return null; - return { - configNames: records.flatMap((record) => Array.isArray(record.configNames) ? record.configNames : []), - connectionNames: records.flatMap((record) => Array.isArray(record.connectionNames) ? record.connectionNames : []), - journalFiles: records.map((record) => record.path), - }; -} - -async function savePendingCleanup(record) { - await fs.mkdir(PENDING_CLEANUP_DIR, { recursive: true, mode: 0o700 }); - await fs.chmod(PENDING_CLEANUP_DIR, 0o700).catch(() => {}); - const id = randomBytes(16).toString("hex"); - const tempPath = join(PENDING_CLEANUP_DIR, `${id}.tmp`); - const path = join(PENDING_CLEANUP_DIR, `${id}.json`); - await fs.writeFile(tempPath, JSON.stringify(record, null, 2) + "\n", { encoding: "utf8", mode: 0o600, flag: "wx" }); - try { - await fs.chmod(tempPath, 0o600).catch(() => {}); - await fs.rename(tempPath, path); - return path; - } catch (error) { - try { - await fs.unlink(tempPath); - } catch (cleanupError) { - if (cleanupError?.code !== "ENOENT") { - throw new AggregateError([error, cleanupError], "Failed to save connector cleanup retry data."); - } - } - throw error; - } -} - -async function clearPendingCleanups(paths) { - for (const path of new Set(paths)) { - try { - await fs.unlink(path); - } catch (error) { - if (error?.code !== "ENOENT") throw error; - } - } -} - -// Validate the MCP endpoint URL before persisting it alongside an API key. The -// value comes from an authenticated ARM read of the user's own gateway, so this -// is defense in depth: require https, reject embedded credentials, and block -// obvious internal/link-local hosts. -export function assertSafeMcpTarget(rawUrl) { - let u; - try { u = new URL(rawUrl); } catch { throw new Error("MCP endpoint URL is not a valid URL."); } - if (u.protocol !== "https:") throw new Error("MCP endpoint URL must use https."); - if (u.username || u.password) throw new Error("MCP endpoint URL must not embed credentials."); - const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase(); - const isIpv6 = host.includes(":"); - const blocked = - host === "localhost" || host.endsWith(".localhost") || - host === "metadata.google.internal" || host === "0.0.0.0" || - (isIpv6 && (host === "::1" || host.startsWith("fe80:") || host.startsWith("fc") || host.startsWith("fd"))) || - /^(127|10)\./.test(host) || - /^169\.254\./.test(host) || - /^192\.168\./.test(host) || - /^172\.(1[6-9]|2\d|3[01])\./.test(host); - if (blocked) throw new Error(`MCP endpoint URL host is not allowed: ${host}`); -} - -// --------------------------------------------------------------------------- -// ARM helpers (using the shared token) -// --------------------------------------------------------------------------- - -// ARM occasionally answers with a transient 5xx/429 (backend blip, throttling) -// that clears on a retry. A single one of these shouldn't nuke a whole connect -// flow, so arm() retries them a few times with backoff before surfacing. -const ARM_TRANSIENT = new Set([429, 500, 502, 503, 504]); -const ARM_MAX_ATTEMPTS = 3; -const ARM_BACKOFF_MS = 500; - -async function arm(method, url, body) { - const token = await getToken(); - const headers = { Authorization: `Bearer ${token}`, Accept: "application/json" }; - if (body !== undefined) headers["Content-Type"] = "application/json"; - const fullUrl = url.startsWith("http") ? url : `https://management.azure.com${url}`; - // Guard the exact value handed to fetch so a tainted path segment can never - // redirect the call off ARM. assertArmHost throws unless fullUrl targets - // https://management.azure.com/. - const safeUrl = assertArmHost(fullUrl); - - for (let attempt = 1; ; attempt++) { - const res = await fetch(safeUrl, { - method, - headers, - body: body !== undefined ? JSON.stringify(body) : undefined, - }); - const text = await res.text(); - let parsed; - try { parsed = text ? JSON.parse(text) : undefined; } catch { parsed = text; } - if (res.ok) return parsed; - - const msg = parsed?.error?.message ?? parsed?.message ?? text ?? `HTTP ${res.status}`; - const err = new Error(`ARM ${method} ${res.status}: ${msg}`); - err.status = res.status; - - // Every ARM call we make is idempotent (GET/PUT/DELETE) or a list-shaped - // POST (listConsentLinks, listApiKey) that returns the same value on - // retry, so retrying a transient failure can't spawn duplicate side - // effects. A 500 is never treated like a 404 elsewhere, so a blip can't - // trigger teardown of a live resource. - if (!ARM_TRANSIENT.has(res.status) || attempt >= ARM_MAX_ATTEMPTS) throw err; - await sleep(ARM_BACKOFF_MS * Math.pow(3, attempt - 1)); // 0.5s, then 1.5s - } -} - -// DELETE that tolerates "already gone" (404) but surfaces every other failure -// instead of silently swallowing it. -async function armDelete(url) { - try { - return await arm("DELETE", url); - } catch (e) { - if (e.status === 404) return undefined; - throw e; - } -} - -// --------------------------------------------------------------------------- -// Naming helpers -// --------------------------------------------------------------------------- - -function shortId() { return randomBytes(3).toString("hex"); } -function sanitize(s) { return String(s).replace(/[^a-zA-Z0-9]+/g, "").slice(0, 24) || "x"; } -function generateName(displayName) { return `${sanitize(displayName)}-${shortId()}`; } -function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } - -// --------------------------------------------------------------------------- -// Connector metadata (connection parameters + agentic operation id) -// --------------------------------------------------------------------------- - -const MANAGED_API_VERSION = "2022-09-01-preview"; -const metaCache = new Map(); // apiName + swagger requirement -> Promise - -export function loadConnectorMeta(config, apiName, location, requireSwagger = true) { - const sub = armSegment(config.subscriptionId); - const cacheKey = `${sub}:${location}:${apiName}:${requireSwagger}`; - if (metaCache.has(cacheKey)) return metaCache.get(cacheKey); - const promise = (async () => { - const base = `/subscriptions/${sub}/providers/Microsoft.Web/locations/${armSegment(location)}/managedApis/${armSegment(apiName)}`; - const metaRequest = arm("GET", `${base}?api-version=${MANAGED_API_VERSION}`); - const swaggerRequest = requireSwagger - ? arm("GET", `${base}?api-version=${MANAGED_API_VERSION}&export=true`) - : Promise.resolve(undefined); - const [meta, swagger] = await Promise.all([metaRequest, swaggerRequest]); - return { - connectionParameters: meta?.properties?.connectionParameters ?? null, - connectionParameterSets: meta?.properties?.connectionParameterSets ?? null, - opId: swagger ? getMcpServerOperationId(swagger) : undefined, - }; - })(); - // Cache the in-flight promise so a fast Connect click reuses the prewarm - // fetch instead of starting a second swagger export. Evict on hard failure - // so a transient error doesn't poison the cache. - promise.catch(() => metaCache.delete(cacheKey)); - metaCache.set(cacheKey, promise); - return promise; -} - -// Fire-and-forget pre-warm so the slow swagger fetch happens while the user is -// reading the catalog, not when they click Connect. Concurrency is bounded so a -// large catalog (~43 MCP servers, each 2 ARM GETs) doesn't burst ~86 parallel -// requests on open and trip rate limits. Items are warmed in catalog order, so -// the servers nearest the top of the view warm first. -export function prewarmMeta(config, apiNames, location) { - Promise.resolve(location || getGatewayLocation(config)).then(async (loc) => { - const sub = armSegment(config.subscriptionId); - const pending = apiNames.filter((name) => !metaCache.has(`${sub}:${loc}:${name}:true`)); - const CONCURRENCY = 5; - let next = 0; - const worker = async () => { - while (next < pending.length) { - const apiName = pending[next++]; - await loadConnectorMeta(config, apiName, loc).catch(() => {}); - } - }; - const poolSize = Math.min(CONCURRENCY, pending.length); - await Promise.all(Array.from({ length: poolSize }, worker)); - }).catch(() => {}); -} - -function getMcpServerOperationId(swagger) { - if (!swagger?.paths) return undefined; - for (const methods of Object.values(swagger.paths)) { - if (!methods || typeof methods !== "object") continue; - const post = methods.post; - if (!post?.operationId) continue; - const tags = (post.tags ?? []).map((t) => String(t).toLowerCase()); - if (tags.includes("deprecated")) continue; - if (tags.includes("agentic")) return post.operationId; - } - return undefined; -} - -// Find the OAuth connection parameter name. The consent call 500s if we send a -// parameterName the connector doesn't declare, so derive it from metadata. -function findOAuthParam(meta, redirectUrl) { - const fallback = { parameterName: "token", redirectUrl }; - let params; - if (meta?.connectionParameterSets?.values?.length) { - params = meta.connectionParameterSets.values[0].parameters; - } else { - params = meta?.connectionParameters; - } - if (!params) return fallback; - for (const [name, param] of Object.entries(params)) { - if (param?.type === "oauthSetting" || param?.oAuthSettings) { - return { parameterName: name, redirectUrl }; - } - } - return fallback; -} - -// --------------------------------------------------------------------------- -// JWT decode (to get user oid/tid for access policy) -// --------------------------------------------------------------------------- - -function decodeJwtPayload(token) { - const parts = token.split("."); - if (parts.length < 2) throw new Error("Invalid JWT"); - const b64 = parts[1].replace(/-/g, "+").replace(/_/g, "/"); - const pad = b64.length % 4 === 0 ? "" : "=".repeat(4 - (b64.length % 4)); - return JSON.parse(Buffer.from(b64 + pad, "base64").toString("utf8")); -} - -async function getUserContext() { - const token = await getToken(); - const claims = decodeJwtPayload(token); - return { objectId: claims.oid, tenantId: claims.tid }; -} - -// --------------------------------------------------------------------------- -// Connection management -// --------------------------------------------------------------------------- - -function gatewayId(config) { - return `/subscriptions/${armSegment(config.subscriptionId)}/resourceGroups/${armSegment(config.resourceGroup)}/providers/Microsoft.Web/connectorGateways/${armSegment(config.gatewayName)}`; -} - -const API_VERSION = "2026-05-01-preview"; - -const s_locationCache = new Map(); // gatewayId -> location (immutable per gateway) - -export async function getGatewayLocation(config) { - const id = gatewayId(config); - const cached = s_locationCache.get(id); - if (cached) return cached; - const gw = await arm("GET", `${id}?api-version=${API_VERSION}`); - const loc = (gw.location ?? "").toLowerCase().replace(/\s+/g, ""); - if (loc) s_locationCache.set(id, loc); - return loc; -} - -export async function createConnection(config, apiName, displayName, location) { - const connName = generateName(displayName); - await arm("PUT", `${gatewayId(config)}/connections/${connName}?api-version=${API_VERSION}`, { - location, - properties: { displayName, connectorName: apiName }, - }); - // Grant current user access policy - try { - const { objectId, tenantId } = await getUserContext(); - await arm("PUT", `${gatewayId(config)}/connections/${connName}/accessPolicies/user-${shortId()}?api-version=${API_VERSION}`, { - location, - properties: { principal: { type: "ActiveDirectory", identity: { objectId, tenantId } } }, - }); - } catch { /* non-fatal */ } - return connName; -} - -export async function getConsentUrl(config, connName, callbackUrl, oauthParam) { - const param = oauthParam || { parameterName: "token", redirectUrl: callbackUrl }; - const res = await arm("POST", `${gatewayId(config)}/connections/${armSegment(connName)}/listConsentLinks?api-version=${API_VERSION}`, { - parameters: [{ parameterName: param.parameterName, redirectUrl: param.redirectUrl }], - }); - return res?.value?.[0]?.link || null; -} - -export async function getConnectionStatus(config, connName) { - const conn = await arm("GET", `${gatewayId(config)}/connections/${armSegment(connName)}?api-version=${API_VERSION}`); - return conn?.properties?.statuses?.[0]?.status ?? conn?.properties?.overallStatus ?? "Unknown"; -} - -export async function createMcpServerConfig(config, apiName, displayName, connName, location, opId, configName = generateName(displayName)) { - if (!opId) { - throw new Error(`Cannot configure "${displayName}" as an MCP server: no agentic operation was found in the connector's definition. The connector may not expose an MCP-streamable endpoint, or its swagger failed to load.`); - } - const created = await arm("PUT", `${gatewayId(config)}/mcpserverConfigs/${configName}?api-version=${API_VERSION}`, { - kind: "ManagedMcpServer", - location, - properties: { - description: displayName, - state: "Enabled", - disableApiKeyAuth: false, - // TextOnlyContent must stay false: when true the dataplane wraps tools/list and - // tools/call responses in a base64 "$content" envelope that spec-compliant MCP - // clients cannot parse, so zero tools load. - settings: { TextOnlyContent: false }, - connectors: [{ - name: apiName, - connectionName: connName, - displayName, - operations: [{ name: opId, displayName, description: "" }], - }], - }, - }); - return { configName, endpointUrl: created?.properties?.mcpEndpointUrl || null }; -} - -export async function mintApiKey(config, configName) { - const notAfter = new Date(Date.now() + 365 * 24 * 3600_000).toISOString(); - const res = await arm("POST", `${gatewayId(config)}/listApiKey?api-version=${API_VERSION}`, { - keyType: "Primary", - notAfter, - scope: configName, - }); - return res.key; -} - -export async function getMcpEndpointUrl(config, configName) { - const cfg = await arm("GET", `${gatewayId(config)}/mcpserverConfigs/${armSegment(configName)}?api-version=${API_VERSION}`); - return cfg?.properties?.mcpEndpointUrl || null; -} - -// --------------------------------------------------------------------------- -// MCP config writer -// --------------------------------------------------------------------------- - -async function readMcpConfigAt(path) { - try { - const raw = await fs.readFile(path, "utf8"); - let parsed = JSON.parse(raw); - // Reject arrays and primitives before treating this as a config object. - // JSON.parse can return either (a hand-edited "[]" or a bare number), - // and both break the write path: a string key set on an array is - // silently dropped by JSON.stringify (the new entry would vanish), and - // a primitive throws on property assignment. Fall back to a fresh - // object so writeMcpEntry always persists. - if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) parsed = {}; - if (!parsed.mcpServers || typeof parsed.mcpServers !== "object" || Array.isArray(parsed.mcpServers)) parsed.mcpServers = {}; - return parsed; - } catch (e) { - if (e.code === "ENOENT") return { mcpServers: {} }; - throw e; - } -} - -async function writeMcpConfigAt(path, cfg) { - const directory = dirname(path); - const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`; - await fs.mkdir(directory, { recursive: true, mode: 0o700 }); - await fs.chmod(directory, 0o700).catch(() => {}); - try { - await fs.writeFile(temporary, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); - await fs.chmod(temporary, 0o600).catch(() => {}); - await fs.rename(temporary, path); - await fs.chmod(path, 0o600).catch(() => {}); - } finally { - await fs.unlink(temporary).catch((error) => { - if (error?.code !== "ENOENT") throw error; - }); - } -} - -export async function writeMcpEntry(name, url, key, scope = "profile", meta = null) { - assertSafeMcpTarget(url); - const path = mcpConfigPath(scope); - return withConfigLock(path, async () => { - const cfg = await readMcpConfigAt(path); - cfg.mcpServers[name] = { - url, - headers: { "X-API-Key": key }, - }; - // Stamp ARM provenance as a sibling metadata key. The underscore prefix - // marks it as "metadata, not part of the MCP launch spec" — the CLI - // tolerates and preserves unknown sibling keys across restarts. - if (meta) cfg.mcpServers[name]._connectorNamespace = meta; - await writeMcpConfigAt(path, cfg); - }); -} - -// Remove the entry from whichever scope(s) it lives in. -export async function removeMcpEntry(name) { - let removed = false; - for (const scope of ["profile", "workspace"]) { - let path; - try { path = mcpConfigPath(scope); } catch { continue; } - const removedAtPath = await withConfigLock(path, async () => { - const cfg = await readMcpConfigAt(path); - if (Object.prototype.hasOwnProperty.call(cfg.mcpServers, name)) { - delete cfg.mcpServers[name]; - await writeMcpConfigAt(path, cfg); - return true; - } - return false; - }); - removed ||= removedAtPath; - } - return removed; -} - -async function deleteMcpServerConfigs(config, configNames) { - for (const configName of configNames) { - const url = `${gatewayId(config)}/mcpserverConfigs/${armSegment(configName)}?api-version=${API_VERSION}`; - await armDelete(url); - let pending = true; - for (let i = 0; i < 20; i++) { - try { - await arm("GET", url); - } catch (error) { - if (error?.status === 404) { - pending = false; - break; - } - throw error; - } - await sleep(750); - } - if (pending) throw new Error(`Timed out waiting for connector configuration "${configName}" deletion.`); - } -} - -async function cleanupConnectorResources(config, apiName, configNames, connectionNames, priorJournalFiles = []) { - configNames = [...new Set(configNames.filter(Boolean))]; - connectionNames = [...new Set(connectionNames.filter(Boolean))]; - const gateway = gatewayId(config); - const journalFile = await savePendingCleanup({ gatewayId: gateway, apiName, configNames, connectionNames }); - const journalFiles = [...priorJournalFiles, journalFile]; - - await deleteMcpServerConfigs(config, configNames); - for (const connectionName of connectionNames) { - await armDelete(`${gateway}/connections/${armSegment(connectionName)}?api-version=${API_VERSION}`); - } - for (const configName of configNames) { - await removeMcpEntry(configName); - } - await clearPendingCleanups(journalFiles); -} - -// Remove an installed connector: delete its mcpserverConfig, its connection, -// and its CLI entry. apiName is resolved against the current installed state. -export async function uninstallConnector(config, apiName) { - const state = await getInstalledState(config); - const entry = state[apiName]; - const gateway = gatewayId(config); - const pending = await getPendingCleanup(gateway, apiName); - if (!entry && !pending) return { ok: true, removed: false }; - - const candidates = entry ? (entry._candidates ?? [entry]) : []; - const configNames = [ - ...(pending?.configNames ?? []), - ...candidates.map((candidate) => candidate.configName), - ]; - const connectionNames = [ - ...(pending?.connectionNames ?? []), - ...candidates.map((candidate) => candidate.connectionName), - ]; - await cleanupConnectorResources(config, apiName, configNames, connectionNames, pending?.journalFiles); - - return { ok: true, removed: true }; -} - -// Local-only remove: drop just the CLI mcp entry, leaving the namespace -// resources (mcpserverConfig + connection) intact. This is the default -// "Remove" action — it unwires the connector from Copilot without deleting -// anything on Azure. Fast and local; no armDelete, no convergence poll. -export async function removeLocalEntry(config, apiName) { - const state = await getInstalledState(config); - const entry = state[apiName]; - if (!entry) return { ok: true, removed: false }; - const candidates = entry._candidates ?? [entry]; - for (const candidate of candidates) { - if (candidate.inCli && candidate.configName) await removeMcpEntry(candidate.configName); - } - return { ok: true, removed: true }; -} - -// Best-effort rollback of a connection created during an install the user then -// cancelled. At that point no mcpserverConfig exists yet, so uninstallConnector -// can't see it — delete the orphaned connection directly so the tile honestly -// returns to "Connect" and we don't leak a half-made connection on the namespace. -export async function deleteConnection(config, connName) { - if (!connName) return { ok: true, removed: false }; - await armDelete(`${gatewayId(config)}/connections/${armSegment(connName)}?api-version=${API_VERSION}`); - return { ok: true, removed: true }; -} - -async function throwAfterCleanup(error, cleanups) { - for (const cleanup of cleanups) { - try { - await cleanup(); - } catch (cleanupError) { - throw new AggregateError( - [error, cleanupError], - `${error.message} Cleanup also failed: ${cleanupError.message}`, - ); - } - } - throw error; -} - -// --------------------------------------------------------------------------- -// Full install pipeline -// --------------------------------------------------------------------------- - -function oauthCallbackUrl(callbackBase, connName, capabilityToken = "") { - const url = new URL(`${callbackBase}${encodeURIComponent(connName)}`); - if (capabilityToken) { - url.searchParams.set("cn_token", capabilityToken); - } - return url.toString(); -} - -export async function installConnector(config, apiName, displayName, callbackBase, scope = "profile", capabilityToken = "") { - const pending = await getPendingCleanup(gatewayId(config), apiName); - if (pending) { - await cleanupConnectorResources( - config, - apiName, - pending.configNames, - pending.connectionNames, - pending.journalFiles, - ); - } - const location = await getGatewayLocation(config); - const meta = await loadConnectorMeta(config, apiName, location); - - // 1. Create connection - const connName = await createConnection(config, apiName, displayName, location); - let finishStarted = false; - try { - // The OAuth redirect must carry the connName so the loopback callback keys - // pendingOAuth by the same value the client polls on. - const callbackUrl = oauthCallbackUrl(callbackBase, connName, capabilityToken); - - // 2. Quick wait for the connection to converge — some connectors come up - // Connected without any OAuth (e.g. service principal / key based). - await sleep(800); - const status = await getConnectionStatus(config, connName); - if (status === "Connected") { - finishStarted = true; - return await finishInstall(config, apiName, displayName, connName, location, scope); - } - - // 3. Needs OAuth — derive the correct consent parameter from metadata. - const oauthParam = findOAuthParam(meta, callbackUrl); - const consentUrl = await getConsentUrl(config, connName, callbackUrl, oauthParam); - if (consentUrl) { - return { needsConsent: true, consentUrl, connName, location, freshConnection: true }; - } - - // 4. No consent link and not Connected — try to finish anyway. - finishStarted = true; - return await finishInstall(config, apiName, displayName, connName, location, scope); - } catch (error) { - if (finishStarted) throw error; - return throwAfterCleanup(error, [() => deleteConnection(config, connName)]); - } -} - -export async function finishInstall(config, apiName, displayName, connName, location, scope = "profile") { - let configName; - try { - if (!location) location = await getGatewayLocation(config); - const meta = await loadConnectorMeta(config, apiName, location); - - // Poll connection status up to ~20s for Connected. - const status = await waitForConnected(config, connName); - - // Create MCP server config (endpoint URL comes back on the PUT response). - let endpointUrl; - configName = generateName(displayName); - ({ endpointUrl } = await createMcpServerConfig(config, apiName, displayName, connName, location, meta.opId, configName)); - - // Endpoint URL can lag — poll the config a few times if missing. - for (let i = 0; !endpointUrl && i < 5; i++) { - await sleep(1000); - endpointUrl = await getMcpEndpointUrl(config, configName); - } - if (!endpointUrl) throw new Error(`MCP endpoint URL not available (connection status: ${status}).`); - - // Mint key and write the CLI entry, stamped with ARM provenance so the - // install can be recognised regardless of which connector namespace is - // active when state is next derived. - const key = await mintApiKey(config, configName); - const gw = gatewayId(config); - await writeMcpEntry(configName, endpointUrl, key, scope, { - gatewayId: gw, - mcpServerConfigId: `${gw}/mcpserverConfigs/${armSegment(configName)}`, - connectionId: `${gw}/connections/${armSegment(connName)}`, - apiName, - }); - - return { ok: true, configName, connName, endpointUrl, scope }; - } catch (error) { - const cleanups = []; - if (configName) { - cleanups.push(() => deleteMcpServerConfigs(config, [configName])); - } - cleanups.push(() => deleteConnection(config, connName)); - return throwAfterCleanup(error, cleanups); - } -} - -// --------------------------------------------------------------------------- -// Re-authenticate pipeline (reuse the EXISTING connection + config) -// --------------------------------------------------------------------------- - -// Re-run consent for a connector that's already installed, WITHOUT minting a new -// connection or a new mcpserverConfig. This is what the "Re-authenticate" button -// hits; wiring it to the plain install path is exactly what spawned duplicate -// configs. We resolve the selected install-state candidate (post phase-1 -// selection, that's the config the local session actually points at), re-consent -// its existing connection, and rebind THAT config locally. -// -// Falls back to a fresh installConnector only when there's genuinely nothing to -// re-auth against: no known connection, or the stored connection was deleted -// server-side (listConsentLinks 404s). In the 404 case we drop the orphaned -// config + local entry first so the fallback install can't leave a duplicate. -export async function reauthConnector(config, apiName, displayName, callbackBase, scope = "profile", capabilityToken = "") { - return reauthConnectorWithAttempts(config, apiName, displayName, callbackBase, scope, capabilityToken, new Set()); -} - -async function reauthConnectorWithAttempts(config, apiName, displayName, callbackBase, scope, capabilityToken, attemptedConfigNames) { - const state = await getInstalledState(config); - const selected = state[apiName]; - const candidates = selected?._candidates ?? (selected ? [selected] : []); - const entry = candidates.find((candidate) => !attemptedConfigNames.has(candidate.configName)); - const connName = entry?.connectionName; - - // Nothing installed to re-auth against — treat it as a first-time Connect. - if (!connName) { - return installConnector(config, apiName, displayName, callbackBase, scope, capabilityToken); - } - - const location = await getGatewayLocation(config); - const meta = await loadConnectorMeta(config, apiName, location, false); - const callbackUrl = oauthCallbackUrl(callbackBase, connName, capabilityToken); - const oauthParam = findOAuthParam(meta, callbackUrl); - - let consentUrl; - try { - consentUrl = await getConsentUrl(config, connName, callbackUrl, oauthParam); - } catch (err) { - // The stored connection is gone (deleted in the portal). Clean up the - // now-orphaned config + local entry, then fall through to a clean - // install so we don't strand a dead "Re-authenticate" tile. - if (err.status === 404) { - attemptedConfigNames.add(entry.configName); - const siblingUsesConnection = candidates.some( - (candidate) => candidate.configName !== entry.configName && candidate.connectionName === connName, - ); - await cleanupConnectorResources( - config, - apiName, - entry.configName ? [entry.configName] : [], - siblingUsesConnection ? [] : [connName], - ); - return reauthConnectorWithAttempts( - config, - apiName, - displayName, - callbackBase, - scope, - capabilityToken, - attemptedConfigNames, - ); - } - throw err; - } - - // Re-consent the existing connection; finish rebinds the SAME config. - // configName is carried through so the finish step never creates a new one. - if (consentUrl) { - return { needsConsent: true, consentUrl, connName, location, configName: entry.configName, reauth: true, freshConnection: false }; - } - - // Already consentable without a redirect — just rebind the existing config. - return finishReauth(config, apiName, displayName, connName, entry.configName, location, scope); -} - -// Finish a re-auth: rebind an EXISTING mcpserverConfig to the local CLI. Unlike -// finishInstall this never calls createMcpServerConfig, so a re-auth can't spawn -// a duplicate — it reuses configName, mints a fresh key, and rewrites the entry. -export async function finishReauth(config, apiName, displayName, connName, configName, location, scope = "profile") { - // Defensive: with no config to bind there's nothing to reuse — fall back to - // a normal finish (which creates one). Shouldn't happen on the reauth path. - if (!configName) { - return finishInstall(config, apiName, displayName, connName, location, scope); - } - - // Wait for the re-consented connection to converge (up to ~20s). - const status = await waitForConnected(config, connName); - - // Reuse the existing config's endpoint — poll a few times if it lags. - let endpointUrl = await getMcpEndpointUrl(config, configName); - for (let i = 0; !endpointUrl && i < 5; i++) { - await sleep(1000); - endpointUrl = await getMcpEndpointUrl(config, configName); - } - if (!endpointUrl) throw new Error(`MCP endpoint URL not available (connection status: ${status}).`); - - const key = await mintApiKey(config, configName); - const gw = gatewayId(config); - await writeMcpEntry(configName, endpointUrl, key, scope, { - gatewayId: gw, - mcpServerConfigId: `${gw}/mcpserverConfigs/${armSegment(configName)}`, - connectionId: `${gw}/connections/${armSegment(connName)}`, - apiName, - }); - - return { ok: true, configName, connName, endpointUrl, scope, reauth: true }; -} - -// --------------------------------------------------------------------------- -// Installed-state derivation (source of truth = the gateway + CLI config) -// --------------------------------------------------------------------------- - -export async function getInstalledState(config) { - const wsPath = s_workspaceRoot ? join(s_workspaceRoot, ".mcp.json") : null; - const [configsRes, connectionsRes, profileCfg, workspaceCfg] = await Promise.all([ - arm("GET", `${gatewayId(config)}/mcpserverConfigs?api-version=${API_VERSION}`), - arm("GET", `${gatewayId(config)}/connections?api-version=${API_VERSION}`), - readMcpConfigAt(PROFILE_MCP_PATH), - wsPath ? readMcpConfigAt(wsPath) : Promise.resolve({ mcpServers: {} }), - ]); - - const connByName = new Map(); - for (const c of connectionsRes.value ?? []) connByName.set(c.name, c); - - const profileKeys = new Set(Object.keys(profileCfg.mcpServers ?? {})); - const workspaceKeys = new Set(Object.keys(workspaceCfg.mcpServers ?? {})); - - return deriveInstalledState(configsRes.value ?? [], connByName, profileKeys, workspaceKeys, wsPath); -} - -// Pure derivation, split out so it can be unit-tested without live ARM. -// A single apiName can have MULTIPLE gateway configs (a portal-side add, a -// duplicate Connect, a re-auth that minted a fresh config). Collect every -// config per apiName, then pick ONE deterministically instead of letting ARM -// list order decide (last-wins) — that overwrite is what stranded a tile on -// "Re-authenticate" while a sibling config was actually Connected. -export function deriveInstalledState(configs, connByName, profileKeys, workspaceKeys, wsPath) { - const candidatesByApi = {}; - for (const cfg of configs ?? []) { - const connector = cfg.properties?.connectors?.[0]; - const apiName = connector?.name; - if (!apiName) continue; - const connName = connector?.connectionName; - const conn = connName ? connByName.get(connName) : null; - const connectionStatus = conn?.properties?.statuses?.[0]?.status ?? conn?.properties?.overallStatus ?? "Unknown"; - const inWorkspace = workspaceKeys.has(cfg.name); - const inProfile = profileKeys.has(cfg.name); - (candidatesByApi[apiName] ??= []).push({ - installed: true, - configName: cfg.name, - connectionName: connName || null, - connectionStatus, - inCli: inProfile || inWorkspace, - cliScope: inWorkspace ? "workspace" : (inProfile ? "profile" : null), - cliPath: inWorkspace ? wsPath : (inProfile ? PROFILE_MCP_PATH : null), - }); - } - - // Prefer the config the local session actually points at, and prefer a - // Connected one: inCli && Connected > inCli > Connected > any. Config name - // breaks ties so ARM list order cannot change the selected resource. Keeps the flat - // one-entry-per-apiName shape the renderer + tests expect. - const rank = (e) => (e.inCli ? 2 : 0) + (e.connectionStatus === "Connected" ? 1 : 0); - const byApi = {}; - for (const [apiName, list] of Object.entries(candidatesByApi)) { - list.sort((a, b) => rank(b) - rank(a) || a.configName.localeCompare(b.configName)); - const best = list[0]; - // Internal-only signal for logging; the renderer ignores unknown fields. - byApi[apiName] = list.length > 1 ? { ...best, _configCount: list.length, _candidates: list } : best; - } - return byApi; -} - -// --------------------------------------------------------------------------- -// Browser opener -// --------------------------------------------------------------------------- - -async function launchDetached(command, args) { - await new Promise((resolve, reject) => { - const child = spawn(command, args, { detached: true, stdio: "ignore" }); - child.once("error", reject); - child.once("spawn", () => { - child.unref(); - resolve(); - }); - }); -} - -export async function openInBrowser(url) { - // Only ever hand an http(s) URL to the OS shell — guards against the - // consent URL being anything that could be reinterpreted as a command. - let safe; - try { - const u = new URL(url); - if (u.protocol !== "http:" && u.protocol !== "https:") return; - safe = u.toString(); - } catch { - return; - } - const p = platform(); - if (p === "win32") { - // rundll32 hands the URL to the default protocol handler as a single - // literal argv with no shell parsing — avoids cmd.exe `start` metachar - // and quoting pitfalls. - await launchDetached(await resolveSystemExecutable("rundll32.exe"), ["url.dll,FileProtocolHandler", safe]); - } else if (p === "darwin") { - await launchDetached(await resolveSystemExecutable("open"), [safe]); - } else { - await launchDetached(await resolveSystemExecutable("xdg-open"), [safe]); - } -} - -// --------------------------------------------------------------------------- -// Config file opener -// --------------------------------------------------------------------------- - -// Hand a local file path to the OS so it opens in the user's default handler -// for that type (typically their editor for .json). Single literal argv on -// every platform — no shell, so a path with spaces or metachars is safe. -async function openPath(filePath) { - const p = platform(); - if (p === "win32") { - // FileProtocolHandler also accepts plain file paths and routes them to - // the registered default app, same no-shell guarantee as openInBrowser. - await launchDetached(await resolveSystemExecutable("rundll32.exe"), ["url.dll,FileProtocolHandler", filePath]); - } else if (p === "darwin") { - await launchDetached(await resolveSystemExecutable("open"), [filePath]); - } else { - await launchDetached(await resolveSystemExecutable("xdg-open"), [filePath]); - } -} - -// Open the MCP config this canvas writes to (the profile scope — -// ~/.copilot/mcp-config.json). Creates an empty, correctly-shaped config if -// none exists yet so the editor never opens a missing file. Returns the path -// either way so the UI can show where it lives even if the OS open is a no-op. -export async function openMcpConfigFile() { - const path = PROFILE_MCP_PATH; - try { - await fs.access(path); - } catch { - try { - await fs.mkdir(dirname(path), { recursive: true }); - await fs.writeFile(path, JSON.stringify({ mcpServers: {} }, null, 2) + "\n", { encoding: "utf8", mode: 0o600 }); - await fs.chmod(path, 0o600).catch(() => {}); - } catch (err) { - return { ok: false, path, error: err.message }; - } - } - await openPath(path); - return { ok: true, path }; -} diff --git a/extensions/connector-namespaces/install.reauth.test.mjs b/extensions/connector-namespaces/install.reauth.test.mjs deleted file mode 100644 index b1f2cfa00..000000000 --- a/extensions/connector-namespaces/install.reauth.test.mjs +++ /dev/null @@ -1,558 +0,0 @@ -// Phase 2 regression: Re-authenticate must re-consent the EXISTING connection and -// mint NO new resources. -// -// Before the fix, the "Re-authenticate" button ran the full install path, so it -// created a fresh connection + a fresh mcpserverConfig on every click. A teammate -// saw a new Dynamics config appear on the namespace each time they re-authed, while -// the panel stayed stuck on "Re-authenticate". This test stubs ARM and proves -// reauthConnector adopts the local session's connection and issues ZERO PUTs. -// -// Run: node --test extensions/connector-namespaces/install.reauth.test.mjs - -import { test, after } from "node:test"; -import assert from "node:assert/strict"; -import { spawn } from "node:child_process"; -import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { tmpdir } from "node:os"; - -// Isolate COPILOT_HOME before importing install.mjs because its paths are bound at -// module-eval time. Seed the interactive broker with an in-memory token so ARM -// calls stay offline, and seed a profile config so the local entry reads as inCli. -const TMP = mkdtempSync(join(tmpdir(), "cn-reauth-")); -process.env.COPILOT_HOME = TMP; -process.env.USERPROFILE = TMP; // homedir() on Windows -process.env.HOME = TMP; // homedir() on posix - -const legacyAuthCache = join(TMP, "extensions", "connector-namespaces", "artifacts", "auth-cache.json"); -mkdirSync(join(TMP, "extensions", "connector-namespaces", "artifacts"), { recursive: true }); -writeFileSync(legacyAuthCache, JSON.stringify({ accessToken: "legacy", refreshToken: "legacy" })); - -writeFileSync( - join(TMP, "mcp-config.json"), - JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }), -); - -const { interactiveAuth } = await import("./auth.mjs"); -interactiveAuth.credential = { - async getToken() { - return { token: "fake-token", expiresOnTimestamp: Date.now() + 60 * 60 * 1000 }; - }, -}; -interactiveAuth.accessToken = { token: "fake-token", expiresOnTimestamp: Date.now() + 60 * 60 * 1000 }; - -// Dynamic import AFTER the env is set. A static top-level import would be hoisted -// and evaluate install.mjs (binding the paths to the real home) before the env -// assignments run. -const { - deleteConnection, - finishInstall, - getInstalledState, - installConnector, - loadConnectorMeta, - reauthConnector, - removeMcpEntry, - uninstallConnector, -} = await import("./install.mjs"); - -after(() => { - try { - rmSync(TMP, { recursive: true, force: true }); - } catch { - /* best-effort temp cleanup */ - } -}); - -test("re-authenticate re-consents the existing connection and mints no new resources", async (t) => { - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - - // Two configs for one apiName — the bug scenario. configA is a portal-added - // sibling that is NOT in the local CLI; configB is the one the local session - // points at. Both connections are Connected, so selection turns on inCli: - // deriveInstalledState must pick configB, and the re-consent must target conn-b. - const configA = { name: "docusign-aaa", properties: { connectors: [{ name: "docusign", connectionName: "conn-a" }] } }; - const configB = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } }; - const connA = { name: "conn-a", properties: { statuses: [{ status: "Connected" }] } }; - const connB = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } }; - - const calls = []; - const realFetch = globalThis.fetch; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - calls.push({ method, url }); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - - if (method === "POST" && url.includes("/listConsentLinks")) return ok({ value: [{ link: "https://consent.example/redir" }] }); - if (url.includes("/managedApis/") && !url.includes("export=true")) { - return ok({ properties: { connectionParameters: { token: { type: "oauthSetting" } } } }); - } - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [configA, configB] }); - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connA, connB] }); - if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" }); - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - const result = await reauthConnector(config, "docusign", "DocuSign", "https://cb/?c="); - assert.equal(existsSync(legacyAuthCache), false, "the legacy refresh-token cache must be removed without reading it"); - - // Adopts the existing connection, stops at consent, carries the selected config - // through so finish never mints a new one. - assert.equal(result.needsConsent, true); - assert.equal(result.reauth, true); - assert.equal(result.freshConnection, false); - assert.equal(result.connName, "conn-b"); // the inCli config's connection - assert.equal(result.configName, "docusign-bbb"); // never a fresh generateName() - - // The core guarantee: nothing was minted. createConnection and - // createMcpServerConfig are the only PUTs on the install path; re-auth issues none. - const puts = calls.filter((c) => c.method === "PUT"); - assert.deepEqual(puts, [], `expected zero PUTs, saw: ${puts.map((p) => p.url).join(", ")}`); - - // And it re-consented the SELECTED connection, not the portal sibling. - const consent = calls.find((c) => c.url.includes("/listConsentLinks")); - assert.ok(consent && consent.url.includes("/connections/conn-b/"), "consent must target conn-b"); - assert.ok( - !calls.some((c) => c.url.includes("/connections/conn-a/listConsentLinks")), - "must not touch the sibling connection conn-a", - ); - assert.ok(!calls.some((c) => c.url.includes("export=true")), "reauth must not request unused swagger"); -}); - -test("missing selected connection re-evaluates a valid duplicate before installing", async (t) => { - const configPath = join(TMP, "mcp-config.json"); - writeFileSync( - configPath, - JSON.stringify({ mcpServers: { "api-dead": { type: "http", url: "https://example.com/mcp" } } }), - ); - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const dead = { name: "api-dead", properties: { connectors: [{ name: "shared-api", connectionName: "conn-dead" }] } }; - const live = { name: "api-live", properties: { connectors: [{ name: "shared-api", connectionName: "conn-live" }] } }; - const calls = []; - const realFetch = globalThis.fetch; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - calls.push({ method, url }); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [dead, live] }); - if (method === "GET" && /\/connections\?/.test(url)) { - return ok({ value: [ - { name: "conn-dead", properties: { statuses: [{ status: "Unknown" }] } }, - { name: "conn-live", properties: { statuses: [{ status: "Connected" }] } }, - ] }); - } - if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" }); - if (method === "GET" && url.includes("/managedApis/shared-api")) { - return ok({ properties: { connectionParameters: { token: { type: "oauthSetting" } } } }); - } - if (method === "POST" && url.includes("/connections/conn-dead/listConsentLinks")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - if (method === "POST" && url.includes("/connections/conn-live/listConsentLinks")) { - return ok({ value: [{ link: "https://consent.example/live" }] }); - } - if (method === "DELETE" && url.includes("/mcpserverConfigs/api-dead")) return ok({}); - if (method === "GET" && url.includes("/mcpserverConfigs/api-dead")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - if (method === "DELETE" && url.includes("/connections/conn-dead")) return ok({}); - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); - }); - - const result = await reauthConnector(config, "shared-api", "Shared API", "https://cb/?c="); - assert.equal(result.needsConsent, true); - assert.equal(result.configName, "api-live"); - assert.equal(result.connName, "conn-live"); - assert.ok(calls.some((call) => call.url.includes("/connections/conn-dead/listConsentLinks"))); - assert.ok(calls.some((call) => call.url.includes("/connections/conn-live/listConsentLinks"))); - assert.equal(calls.some((call) => call.method === "PUT"), false, "valid siblings must prevent a fresh install"); -}); - -test("cross-process MCP config writes preserve every entry", async () => { - const configPath = join(TMP, "mcp-config.json"); - writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); - const installUrl = new URL("./install.mjs", import.meta.url).href; - const names = Array.from({ length: 8 }, (_, index) => `parallel-${index}`); - - const runWriter = (name) => new Promise((resolve, reject) => { - const metadata = { - gatewayId: "/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway", - mcpServerConfigId: `/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway/mcpserverConfigs/${name}`, - connectionId: `/subscriptions/sub/resourceGroups/rg/providers/Microsoft.Web/connectorGateways/gateway/connections/${name}`, - apiName: name, - }; - const script = [ - `import { writeMcpEntry } from ${JSON.stringify(installUrl)};`, - `await writeMcpEntry(${JSON.stringify(name)}, ${JSON.stringify(`https://example.com/${name}`)}, ${JSON.stringify(`key-${name}`)}, "profile", ${JSON.stringify(metadata)});`, - ].join("\n"); - const child = spawn(process.execPath, ["--input-type=module", "--eval", script], { - env: { ...process.env, COPILOT_HOME: TMP, HOME: TMP, USERPROFILE: TMP }, - stdio: ["ignore", "ignore", "pipe"], - }); - let stderr = ""; - child.stderr.on("data", (chunk) => { stderr += chunk; }); - child.once("error", reject); - child.once("exit", (code) => { - if (code === 0) resolve(); - else reject(new Error(`config writer exited ${code}: ${stderr}`)); - }); - }); - - await Promise.all(names.map(runWriter)); - const stored = JSON.parse(readFileSync(configPath, "utf8")).mcpServers; - assert.deepEqual(Object.keys(stored).sort(), [...names].sort()); - for (const name of names) { - assert.equal(stored[name].url, `https://example.com/${name}`); - assert.equal(stored[name].headers["X-API-Key"], `key-${name}`); - assert.deepEqual(Object.keys(stored[name]).sort(), ["_connectorNamespace", "headers", "url"]); - assert.equal(stored[name]._connectorNamespace.apiName, name); - assert.match(stored[name]._connectorNamespace.gatewayId, /connectorGateways\/gateway$/); - } - assert.equal(existsSync(`${configPath}.lock`), false); - - await Promise.all(names.map((name) => removeMcpEntry(name))); - assert.deepEqual(JSON.parse(readFileSync(configPath, "utf8")).mcpServers, {}); -}); - -test("connector metadata failures are evicted and retried", async (t) => { - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const realFetch = globalThis.fetch; - let calls = 0; - globalThis.fetch = async (urlArg) => { - const url = String(urlArg); - assert.ok(!url.includes("export=true"), "swagger must not be requested when it is not required"); - calls++; - if (calls === 1) return { ok: false, status: 400, text: async () => "temporary metadata failure" }; - return { - ok: true, - status: 200, - text: async () => JSON.stringify({ properties: { connectionParameters: {} } }), - }; - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - await assert.rejects(loadConnectorMeta(config, "retry-meta", "eastus", false), /metadata failure/); - const meta = await loadConnectorMeta(config, "retry-meta", "eastus", false); - assert.equal(calls, 2); - assert.deepEqual(meta.connectionParameters, {}); -}); - -test("uninstall surfaces connection deletion failures", async (t) => { - writeFileSync( - join(TMP, "mcp-config.json"), - JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }), - ); - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const remoteConfig = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } }; - const connection = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } }; - const realFetch = globalThis.fetch; - const operations = []; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - operations.push(`${method} ${url}`); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] }); - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connection] }); - if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({}); - if (method === "GET" && url.includes("/mcpserverConfigs/")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - if (method === "DELETE" && url.includes("/connections/")) { - return { ok: false, status: 400, text: async () => "delete denied" }; - } - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - await assert.rejects(uninstallConnector(config, "docusign"), /delete denied/); - const configDelete = operations.findIndex((item) => item.startsWith("DELETE ") && item.includes("/mcpserverConfigs/")); - const connectionDelete = operations.findIndex((item) => item.startsWith("DELETE ") && item.includes("/connections/")); - assert.ok(configDelete !== -1 && configDelete < connectionDelete, "configs must be confirmed deleted before their connections"); - - const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup"); - assert.equal(readdirSync(pendingCleanup).filter((name) => name.endsWith(".json")).length, 1, "failed deletion must persist enough state to retry"); - - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [] }); - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] }); - if (method === "DELETE") return ok({}); - if (method === "GET" && url.includes("/mcpserverConfigs/")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - assert.deepEqual(await uninstallConnector(config, "docusign"), { ok: true, removed: true }); - assert.equal(readdirSync(pendingCleanup).filter((name) => name.endsWith(".json")).length, 0, "successful retry must clear the cleanup journal"); -}); - -test("uninstall surfaces convergence polling failures", async (t) => { - writeFileSync( - join(TMP, "mcp-config.json"), - JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }), - ); - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const remoteConfig = { name: "docusign-bbb", properties: { connectors: [{ name: "docusign", connectionName: "conn-b" }] } }; - const connection = { name: "conn-b", properties: { statuses: [{ status: "Connected" }] } }; - const realFetch = globalThis.fetch; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] }); - if (method === "GET" && url.includes("/mcpserverConfigs/")) { - return { ok: false, status: 400, text: async () => "poll denied" }; - } - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [connection] }); - if (method === "DELETE") return ok({}); - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - await assert.rejects(uninstallConnector(config, "docusign"), /poll denied/); -}); - -test("concurrent failed uninstalls retain independent retry records", async (t) => { - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "concurrent-gw" }; - const configs = [ - { name: "alpha-config", properties: { connectors: [{ name: "alpha", connectionName: "alpha-conn" }] } }, - { name: "beta-config", properties: { connectors: [{ name: "beta", connectionName: "beta-conn" }] } }, - ]; - const connections = [ - { name: "alpha-conn", properties: { statuses: [{ status: "Connected" }] } }, - { name: "beta-conn", properties: { statuses: [{ status: "Connected" }] } }, - ]; - const realFetch = globalThis.fetch; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: configs }); - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: connections }); - if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({}); - if (method === "GET" && url.includes("/mcpserverConfigs/")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - if (method === "DELETE" && url.includes("/connections/")) { - return { ok: false, status: 400, text: async () => "delete denied" }; - } - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - const results = await Promise.allSettled([ - uninstallConnector(config, "alpha"), - uninstallConnector(config, "beta"), - ]); - assert.deepEqual(results.map((result) => result.status), ["rejected", "rejected"]); - - const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup"); - const paths = readdirSync(pendingCleanup) - .filter((name) => name.endsWith(".json")) - .map((name) => join(pendingCleanup, name)); - const records = paths.map((path) => ({ path, ...JSON.parse(readFileSync(path, "utf8")) })) - .filter((record) => record.gatewayId.includes("/connectorGateways/concurrent-gw")); - assert.deepEqual(new Set(records.map((record) => record.apiName)), new Set(["alpha", "beta"])); - for (const record of records) unlinkSync(record.path); -}); - -test("local MCP config read failures block cleanup", async () => { - const configPath = join(TMP, "mcp-config.json"); - writeFileSync(configPath, "{invalid json"); - try { - await assert.rejects(removeMcpEntry("docusign-bbb"), SyntaxError); - } finally { - writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); - } -}); - -test("installed state propagates local MCP config read failures", async (t) => { - const configPath = join(TMP, "mcp-config.json"); - writeFileSync(configPath, "{invalid json"); - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "state-fail-gw" }; - const realFetch = globalThis.fetch; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [] }); - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] }); - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); - }); - - await assert.rejects(getInstalledState(config), SyntaxError); -}); - -test("missing-connection reauth journals cleanup and the next install retries it", async (t) => { - const configPath = join(TMP, "mcp-config.json"); - writeFileSync( - configPath, - JSON.stringify({ mcpServers: { "missing-config": { type: "http", url: "https://example/mcp" } } }), - ); - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "missing-conn-gw" }; - const remoteConfig = { - name: "missing-config", - properties: { connectors: [{ name: "missing-api", connectionName: "missing-conn" }] }, - }; - const realFetch = globalThis.fetch; - let retrying = false; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "DELETE" && url.includes("/mcpserverConfigs/")) return ok({}); - if (method === "GET" && url.includes("/mcpserverConfigs/missing-config")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - if (method === "DELETE" && url.includes("/connections/missing-conn")) { - return { ok: false, status: 404, text: async () => "gone" }; - } - if (retrying && method === "GET" && url.includes("/managedApis/missing-api")) { - return { ok: false, status: 400, text: async () => "stop after cleanup" }; - } - if (method === "GET" && /\/mcpserverConfigs\?/.test(url)) return ok({ value: [remoteConfig] }); - if (method === "GET" && /\/connections\?/.test(url)) return ok({ value: [] }); - if (method === "GET" && /\/connectorGateways\/[^/?]+\?/.test(url)) return ok({ location: "eastus" }); - if (method === "GET" && url.includes("/managedApis/missing-api")) return ok({ properties: {} }); - if (method === "POST" && url.includes("/connections/missing-conn/listConsentLinks")) { - writeFileSync(configPath, "{invalid json"); - return { ok: false, status: 404, text: async () => "connection gone" }; - } - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - writeFileSync(configPath, JSON.stringify({ mcpServers: {} })); - }); - - await assert.rejects( - reauthConnector(config, "missing-api", "Missing API", "https://cb/?c="), - SyntaxError, - ); - - const pendingCleanup = join(TMP, "extensions", "connector-namespaces", "artifacts", "pending-cleanup"); - const matchingRecords = () => readdirSync(pendingCleanup) - .filter((name) => name.endsWith(".json")) - .map((name) => JSON.parse(readFileSync(join(pendingCleanup, name), "utf8"))) - .filter((record) => record.gatewayId.includes("/connectorGateways/missing-conn-gw") && record.apiName === "missing-api"); - assert.equal(matchingRecords().length, 1, "failed reauth cleanup must retain retry data"); - - writeFileSync( - configPath, - JSON.stringify({ mcpServers: { "missing-config": { type: "http", url: "https://example/mcp" } } }), - ); - retrying = true; - await assert.rejects( - installConnector(config, "missing-api", "Missing API", "https://cb/?c="), - /stop after cleanup/, - ); - assert.equal(matchingRecords().length, 0, "the next install must consume successful pending cleanup"); - const localConfig = JSON.parse(readFileSync(configPath, "utf8")); - assert.equal(localConfig.mcpServers["missing-config"], undefined, "pending cleanup must remove the stale local entry"); -}); - -test("fresh-connection rollback surfaces deletion failures", async (t) => { - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const realFetch = globalThis.fetch; - globalThis.fetch = async () => ({ ok: false, status: 400, text: async () => "rollback denied" }); - t.after(() => { - globalThis.fetch = realFetch; - }); - - await assert.rejects(deleteConnection(config, "fresh-conn"), /rollback denied/); -}); - -test("finish status failures roll back the fresh connection", async (t) => { - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const realFetch = globalThis.fetch; - const calls = []; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - calls.push({ method, url }); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && url.includes("/managedApis/") && url.includes("export=true")) { - return ok({ paths: { "/mcp": { post: { operationId: "op", tags: ["agentic"] } } } }); - } - if (method === "GET" && url.includes("/managedApis/")) return ok({ properties: {} }); - if (method === "GET" && url.includes("/connections/fresh-status?")) { - return { ok: false, status: 400, text: async () => "status denied" }; - } - if (method === "DELETE" && url.includes("/connections/fresh-status?")) return ok({}); - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - await assert.rejects( - finishInstall(config, "status-fail", "Status Fail", "fresh-status", "eastus"), - /status denied/, - ); - assert.ok(calls.some((call) => call.method === "DELETE" && call.url.includes("/connections/fresh-status?"))); -}); - -test("failed config cleanup preserves its referenced connection", async (t) => { - const config = { subscriptionId: "sub1", resourceGroup: "rg1", gatewayName: "gw1" }; - const realFetch = globalThis.fetch; - const calls = []; - globalThis.fetch = async (urlArg, opts = {}) => { - const url = String(urlArg); - const method = (opts.method || "GET").toUpperCase(); - calls.push({ method, url }); - const ok = (body) => ({ ok: true, status: 200, text: async () => JSON.stringify(body) }); - if (method === "GET" && url.includes("/managedApis/") && url.includes("export=true")) { - return ok({ paths: { "/mcp": { post: { operationId: "op", tags: ["agentic"] } } } }); - } - if (method === "GET" && url.includes("/managedApis/")) return ok({ properties: {} }); - if (method === "GET" && url.includes("/connections/fresh-config?")) { - return ok({ properties: { statuses: [{ status: "Connected" }] } }); - } - if (method === "PUT" && url.includes("/mcpserverConfigs/")) { - return ok({ properties: { mcpEndpointUrl: "https://example.com/mcp" } }); - } - if (method === "POST" && url.includes("/listApiKey?")) { - return { ok: false, status: 400, text: async () => "key denied" }; - } - if (method === "DELETE" && url.includes("/mcpserverConfigs/")) { - return { ok: false, status: 400, text: async () => "config cleanup denied" }; - } - if (method === "DELETE" && url.includes("/connections/")) return ok({}); - throw new Error(`unexpected ARM call: ${method} ${url}`); - }; - t.after(() => { - globalThis.fetch = realFetch; - }); - - await assert.rejects( - finishInstall(config, "cleanup-order", "Cleanup Order", "fresh-config", "eastus"), - /config cleanup denied/, - ); - assert.ok( - !calls.some((call) => call.method === "DELETE" && call.url.includes("/connections/")), - "a surviving config must keep its referenced connection", - ); -}); diff --git a/extensions/connector-namespaces/install.test.mjs b/extensions/connector-namespaces/install.test.mjs deleted file mode 100644 index 5e51de54a..000000000 --- a/extensions/connector-namespaces/install.test.mjs +++ /dev/null @@ -1,253 +0,0 @@ -// Regression guards for install-state selection. -// -// Run: node --test extensions/connector-namespaces/install.test.mjs -// -// These exist because getInstalledState used to collapse N gateway configs for -// one apiName down to a single tile via ARM list order (last-wins). A portal -// add, a duplicate Connect, or a re-auth would mint a sibling config; whichever -// ARM happened to return last owned the tile, so a tile could show -// "Re-authenticate" while a different config for the same connector was already -// Connected. deriveInstalledState now picks deterministically: -// inCli && Connected > inCli > Connected > any, configName wins ties. - -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; - -import { deriveInstalledState, getConsentUrl, getConnectionStatus, getMcpEndpointUrl, waitForConnected } from "./install.mjs"; - -// removeLocalEntry does file I/O (getInstalledState reads ARM + mcp configs, -// removeMcpEntry edits them) and calls both as same-module functions, so there -// is no import seam to stub. The invariant that matters — the default "Remove" -// only unlinks the CLI entry and NEVER deletes the Azure resource — is a -// source contract, so we assert it against the function body the same way -// renderer.test.mjs guards its CSS/HTML strings. -function functionBody(source, name) { - const exported = source.indexOf(`export async function ${name}(`); - const start = exported !== -1 ? exported : source.indexOf(`async function ${name}(`); - if (start === -1) return null; - const open = source.indexOf("{", start); - if (open === -1) return null; - let depth = 0; - for (let i = open; i < source.length; i++) { - const ch = source[i]; - if (ch === "{") depth++; - else if (ch === "}") { - depth--; - if (depth === 0) return source.slice(open + 1, i); - } - } - return null; -} - -const installSource = readFileSync(fileURLToPath(new URL("./install.mjs", import.meta.url)), "utf8"); - -// Build a fake ARM mcpserverConfig list entry. -function cfg(name, apiName, connName) { - return { name, properties: { connectors: [{ name: apiName, connectionName: connName }] } }; -} - -// Build a connName -> connection map with a given status. -function conns(...entries) { - const m = new Map(); - for (const [connName, status] of entries) { - m.set(connName, { name: connName, properties: { statuses: [{ status }] } }); - } - return m; -} - -test("picks inCli+Connected over a not-inCli sibling that appears LAST (not last-wins)", () => { - // good config is FIRST; a broken sibling is LAST. Old last-wins would pick - // the last one — the fix must pick the good one regardless of order. - const configs = [ - cfg("good", "shared-api", "connGood"), - cfg("bad", "shared-api", "connBad"), - ]; - const connByName = conns(["connGood", "Connected"], ["connBad", "Unknown"]); - const profileKeys = new Set(["good"]); // only the good config is in the CLI - const state = deriveInstalledState(configs, connByName, profileKeys, new Set(), null); - - assert.equal(state["shared-api"].configName, "good"); - assert.equal(state["shared-api"].connectionName, "connGood"); - assert.equal(state["shared-api"].connectionStatus, "Connected"); - assert.equal(state["shared-api"].inCli, true); - assert.equal(state["shared-api"]._configCount, 2); - assert.deepEqual(state["shared-api"]._candidates.map((item) => item.configName), ["good", "bad"]); -}); - -test("inCli beats a Connected-but-not-inCli sibling", () => { - // A is the config the local session points at but not yet Connected; B is - // Connected on ARM but not in the CLI. Prefer A so remove/re-auth act on the - // resource the user's session actually uses. - const configs = [ - cfg("a-incli", "api", "connA"), - cfg("b-connected", "api", "connB"), - ]; - const connByName = conns(["connA", "Unknown"], ["connB", "Connected"]); - const state = deriveInstalledState(configs, connByName, new Set(["a-incli"]), new Set(), null); - - assert.equal(state["api"].configName, "a-incli"); - assert.equal(state["api"].inCli, true); -}); - -test("inCli && Connected beats inCli-only", () => { - const configs = [ - cfg("incli-unknown", "api", "connU"), - cfg("incli-connected", "api", "connC"), - ]; - const connByName = conns(["connU", "Unknown"], ["connC", "Connected"]); - const state = deriveInstalledState(configs, connByName, new Set(["incli-unknown", "incli-connected"]), new Set(), null); - - assert.equal(state["api"].configName, "incli-connected"); - assert.equal(state["api"].connectionStatus, "Connected"); -}); - -test("config name breaks equal-rank ties independently of ARM list order", () => { - const configs = [ - cfg("z-config", "api", "connZ"), - cfg("a-config", "api", "connA"), - ]; - const connByName = conns(["connZ", "Connected"], ["connA", "Connected"]); - const local = new Set(["z-config", "a-config"]); - - const forward = deriveInstalledState(configs, connByName, local, new Set(), null); - const reverse = deriveInstalledState([...configs].reverse(), connByName, local, new Set(), null); - - assert.equal(forward.api.configName, "a-config"); - assert.equal(reverse.api.configName, "a-config"); -}); - -test("connection convergence reports non-connected terminal results as failures", async () => { - const states = ["Connecting", "Error"]; - const delays = []; - await assert.rejects( - waitForConnected({}, "conn", { - maxPolls: 2, - getStatus: async () => states.shift(), - delay: async (ms) => delays.push(ms), - }), - /Connection ended in state "Error"/, - ); - assert.deepEqual(delays, [1000]); - assert.equal( - await waitForConnected({}, "conn", { - getStatus: async () => "Connected", - delay: async () => assert.fail("connected state must not sleep"), - }), - "Connected", - ); -}); - -test("single config passes through with no _configCount", () => { - const configs = [cfg("only", "api", "conn1")]; - const connByName = conns(["conn1", "Connected"]); - const state = deriveInstalledState(configs, connByName, new Set(["only"]), new Set(), null); - - assert.equal(state["api"].configName, "only"); - assert.equal(state["api"]._configCount, undefined); -}); - -test("workspace membership counts as inCli and sets scope/path", () => { - const configs = [cfg("ws", "api", "conn1")]; - const connByName = conns(["conn1", "Connected"]); - const state = deriveInstalledState(configs, connByName, new Set(), new Set(["ws"]), "/repo/.mcp.json"); - - assert.equal(state["api"].inCli, true); - assert.equal(state["api"].cliScope, "workspace"); - assert.equal(state["api"].cliPath, "/repo/.mcp.json"); -}); - -test("connectionStatus falls back to overallStatus then Unknown", () => { - const configs = [cfg("c1", "api1", "connOverall"), cfg("c2", "api2", "connMissing")]; - const connByName = new Map([ - ["connOverall", { name: "connOverall", properties: { overallStatus: "Connected" } }], - ]); - const state = deriveInstalledState(configs, connByName, new Set(), new Set(), null); - - assert.equal(state["api1"].connectionStatus, "Connected"); // from overallStatus - assert.equal(state["api2"].connectionStatus, "Unknown"); // no connection at all -}); - -test("configs with no connector are skipped", () => { - const configs = [ - { name: "broken", properties: { connectors: [] } }, - cfg("ok", "api", "conn1"), - ]; - const connByName = conns(["conn1", "Connected"]); - const state = deriveInstalledState(configs, connByName, new Set(["ok"]), new Set(), null); - - assert.equal(Object.keys(state).length, 1); - assert.equal(state["api"].configName, "ok"); -}); - -test("removeLocalEntry unlinks the local CLI entry via removeMcpEntry", () => { - const body = functionBody(installSource, "removeLocalEntry"); - assert.ok(body, "removeLocalEntry function not found in install.mjs"); - assert.match(body, /removeMcpEntry\s*\(/, "removeLocalEntry must call removeMcpEntry to drop the CLI entry"); - assert.match(body, /entry\._candidates/, "removeLocalEntry must process duplicate CLI configs"); - assert.match(body, /candidate\.inCli/, "removeLocalEntry must unlink every local candidate"); -}); - -test("uninstallConnector deletes every duplicate namespace config", () => { - const body = functionBody(installSource, "uninstallConnector"); - const cleanup = functionBody(installSource, "cleanupConnectorResources"); - assert.ok(body, "uninstallConnector function not found in install.mjs"); - assert.ok(cleanup, "cleanupConnectorResources function not found in install.mjs"); - assert.match(body, /entry\._candidates/, "namespace deletion must process duplicate configs"); - assert.match(body, /cleanupConnectorResources\s*\(/, "uninstall must delegate all collected candidates to shared cleanup"); - assert.match(cleanup, /deleteMcpServerConfigs\(config, configNames\)/); - assert.match(cleanup, /for \(const connectionName of connectionNames\)/); - assert.match(cleanup, /for \(const configName of configNames\)/); -}); - -test("removeLocalEntry never deletes the namespace resource (no armDelete)", () => { - const body = functionBody(installSource, "removeLocalEntry"); - assert.ok(body, "removeLocalEntry function not found in install.mjs"); - // The default Remove must stay local-only. If someone routes it through - // uninstallConnector or adds an ARM delete, this fails — which is the point. - assert.doesNotMatch(body, /armDelete\s*\(/, "removeLocalEntry must not call armDelete"); - assert.doesNotMatch(body, /uninstallConnector\s*\(/, "removeLocalEntry must not delegate to uninstallConnector"); -}); - -// --- ARM path-injection guard (client-reachable read sinks) --- -// -// finishInstall/finishReauth feed client-supplied body.connName / body.configName -// into getConsentUrl, getConnectionStatus, and getMcpEndpointUrl, which build ARM -// URLs. Those names must pass through armSegment() so a traversal / query payload -// can't escape the intended resource path (SSRF / path injection). armSegment -// throws synchronously while the URL is built, before any token or network call, -// so these run fully offline and deterministic. A valid config is used so the -// gatewayId() wrap doesn't throw first — only the bad NAME should reject. -const validConfig = { subscriptionId: "s", resourceGroup: "r", gatewayName: "g" }; -const badNames = ["../../evil", "evil/../../secret", "x?injected=1"]; - -test("getConnectionStatus rejects traversal/injection connName before any ARM call", async () => { - for (const bad of badNames) { - await assert.rejects( - () => getConnectionStatus(validConfig, bad), - /Invalid ARM resource identifier/, - `getConnectionStatus should reject connName ${JSON.stringify(bad)}`, - ); - } -}); - -test("getConsentUrl rejects traversal/injection connName before any ARM call", async () => { - for (const bad of badNames) { - await assert.rejects( - () => getConsentUrl(validConfig, bad, "http://127.0.0.1:0/auth/callback/x"), - /Invalid ARM resource identifier/, - `getConsentUrl should reject connName ${JSON.stringify(bad)}`, - ); - } -}); - -test("getMcpEndpointUrl rejects traversal/injection configName before any ARM call", async () => { - for (const bad of badNames) { - await assert.rejects( - () => getMcpEndpointUrl(validConfig, bad), - /Invalid ARM resource identifier/, - `getMcpEndpointUrl should reject configName ${JSON.stringify(bad)}`, - ); - } -}); diff --git a/extensions/connector-namespaces/mcp-http-probe.test.mjs b/extensions/connector-namespaces/mcp-http-probe.test.mjs deleted file mode 100644 index 009f10864..000000000 --- a/extensions/connector-namespaces/mcp-http-probe.test.mjs +++ /dev/null @@ -1,58 +0,0 @@ -import assert from "node:assert/strict"; -import { createServer } from "node:http"; -import test from "node:test"; -import { probe } from "./test/mcp-probe.mjs"; - -test("native HTTP probe carries API key and MCP session through an SSE handshake", async (t) => { - const apiKeys = []; - const sessionIds = []; - const methods = []; - const server = createServer(async (req, res) => { - const chunks = []; - for await (const chunk of req) chunks.push(chunk); - const message = JSON.parse(Buffer.concat(chunks).toString("utf8")); - apiKeys.push(req.headers["x-api-key"]); - sessionIds.push(req.headers["mcp-session-id"] || null); - methods.push(message.method); - - if (message.method === "notifications/initialized") { - res.writeHead(202); - res.end(); - return; - } - - let result; - if (message.method === "initialize") { - res.setHeader("Mcp-Session-Id", "session-1"); - result = { - protocolVersion: "2025-06-18", - serverInfo: { name: "test-server", version: "1.0.0" }, - capabilities: { tools: {} }, - }; - } else if (message.method === "tools/list") { - result = { tools: [{ name: "ListTeams", inputSchema: { type: "object" } }] }; - } else { - result = { content: [{ type: "text", text: "ok" }] }; - } - - res.setHeader("Content-Type", "text/event-stream"); - res.end(`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: message.id, result })}\n\n`); - }); - await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); - t.after(() => new Promise((resolve) => server.close(resolve))); - - const { port } = server.address(); - const result = await probe({ - apiName: "WorkIQTeams", - displayName: "WorkIQ Teams", - url: `http://127.0.0.1:${port}/mcp`, - key: "secret", - }); - - assert.equal(result.ok, true); - assert.equal(result.toolCount, 1); - assert.equal(result.toolCalled, "ListTeams"); - assert.deepEqual(methods, ["initialize", "notifications/initialized", "tools/list", "tools/call"]); - assert.deepEqual(apiKeys, ["secret", "secret", "secret", "secret"]); - assert.deepEqual(sessionIds, [null, "session-1", "session-1", "session-1"]); -}); diff --git a/extensions/connector-namespaces/package.json b/extensions/connector-namespaces/package.json deleted file mode 100644 index 26f54bbcb..000000000 --- a/extensions/connector-namespaces/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "connector-namespaces", - "version": "1.2.0", - "type": "module", - "main": "extension.mjs", - "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.", - "keywords": [ - "azure", - "connector-namespace", - "mcp", - "mcp-connectors", - "model-context-protocol", - "tool-discovery" - ], - "license": "MIT", - "dependencies": { - "@azure/identity": "4.13.1", - "@github/copilot-sdk": "1.0.6" - } -} diff --git a/extensions/connector-namespaces/preview/.gitignore b/extensions/connector-namespaces/preview/.gitignore deleted file mode 100644 index 0bac4c45f..000000000 --- a/extensions/connector-namespaces/preview/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -# Screenshot evidence is throwaway, regenerated on demand by shots.mjs. -shots/ diff --git a/extensions/connector-namespaces/preview/README.md b/extensions/connector-namespaces/preview/README.md deleted file mode 100644 index 4413d5fb3..000000000 --- a/extensions/connector-namespaces/preview/README.md +++ /dev/null @@ -1,112 +0,0 @@ -# connector-namespaces preview harness - -A standalone way to **see** every canvas state without launching the Copilot -app. It imports the real, pure renderer functions from `../renderer.mjs` and -serves each state on a fixed loopback port, with every `/api/*` endpoint stubbed -so you can force the states that keep regressing (the connecting spinner and the -"Restart the GitHub Copilot app…" banner). - -This exists because those two bugs have each shipped multiple times: - -- the sign-in spinner freezing (an unscoped `animation:none` leaking out of the - reduced-motion block), and -- the restart-banner dismiss button doing nothing (a CSS specificity bug that - let `.restart-banner{display:flex}` beat `[hidden]`). - -Both are static CSS facts, so the **deterministic gate is `../renderer.test.mjs`** -(run with `node --test`). This harness is the human-visual layer on top of it: -load a state in a browser, or capture screenshots with `agent-browser`. - -## Run the preview server - -```sh -node extensions/connector-namespaces/preview/server.mjs -``` - -It binds to `http://127.0.0.1:7331`. Open that URL in any browser. The server is -a plain HTTP process (not the JSON-RPC extension provider), so it logs every hit -to stdout — that's expected and fine here. - -### State routes - -| URL | State | -| --- | --- | -| `/` or `/catalog` | Configured catalog (mock gateway + connectors) | -| `/setup` | First-run gateway picker (`renderSetupHtml`) | -| `/error` | Error screen (`renderErrorHtml`) | - -### State-forcing query flags (on the catalog route) - -The catalog page hydrates from `/api/state` on load, so loading one of these -sets the state the very next `/api/state` returns: - -| Flag | Effect | -| --- | --- | -| `/?restart=1` | `/api/state` returns `pendingRestart:true` → restart banner visible on load | -| `/?installed=1` | One connector shows as already installed/connected | - -Flags combine, e.g. `/?installed=1&restart=1`. - -> The active state is a single module-level flag (last catalog load wins). It's a -> single-user preview, so just load the page you want, then it's sticky until the -> next catalog load. - -### Stubbed endpoints - -`/api/state`, `/api/gateways`, `/api/select-gateway`, `/api/install` (returns -`needsConsent` to force the connecting spinner), `/api/finish-install`, -`/api/ack-restart` (the dismiss action), `/oauth-status` (stays pending so the -modal spinner keeps animating), `/api/uninstall`, `/api/rollback-connection`, -and `/api/open-url` (a deliberate **no-op** here — it must never actually launch -a browser tab). - -## Capture screenshots (optional) - -The screenshot driver uses [`agent-browser`](https://www.npmjs.com/package/agent-browser), -the same headless-Chromium verification tool that `arikbidny/ralph-copilot-cli` -uses. It is **not** required — if it isn't installed the driver prints an install -hint and exits 0. - -Install it once: - -```sh -npm i -g agent-browser && agent-browser install -``` - -Then, with the server running in another terminal: - -```sh -node extensions/connector-namespaces/preview/shots.mjs -``` - -Screenshots are written to `preview/shots/`: - -- `catalog.png`, `catalog-restart-banner.png`, `catalog-installed.png`, - `setup.png`, `error.png` — the static states. -- `connecting-spinner.png` — after clicking **Connect**; verify the `.si-spin` - ring is mid-rotation, not frozen. -- `banner-before-dismiss.png` / `banner-after-dismiss.png` — verify the banner is - present in the first and **gone** in the second. - -`preview/shots/` is throwaway visual evidence; it is not committed. - -## Files - -| File | Purpose | -| --- | --- | -| `server.mjs` | Standalone preview server (fixed port 7331) | -| `fixtures.mjs` | Deterministic mock subscriptions / gateways / catalog / state | -| `shots.mjs` | `agent-browser` screenshot driver (degrades gracefully) | - -## Relationship to the test guard - -`shots.mjs` proves a state *looks* right today and is handy when chasing a new -bug. It cannot prove an animation is *running* from a single frame. The -regression gate that actually blocks the recurring bugs is the CSS-structure -assertion in `../renderer.test.mjs`: - -```sh -node --test extensions/connector-namespaces/renderer.test.mjs -``` - -Keep that green; use this harness to eyeball changes. diff --git a/extensions/connector-namespaces/preview/fixtures.mjs b/extensions/connector-namespaces/preview/fixtures.mjs deleted file mode 100644 index 0fce100b8..000000000 --- a/extensions/connector-namespaces/preview/fixtures.mjs +++ /dev/null @@ -1,117 +0,0 @@ -// Deterministic fixtures for the standalone canvas preview server. -// -// These mirror the exact response shapes the inline client script in -// renderer.mjs expects, so the preview server can drive every canvas state -// (setup / catalog / error / connecting-spinner / restart-banner) with no -// Copilot app, no ARM, and no real OAuth. Keep these shapes in sync with the -// fetch() handlers in renderer.mjs if those response contracts change. - -import { CATEGORY } from "../categories.mjs"; - -export const subscriptions = [ - { id: "00000000-0000-0000-0000-000000000001", name: "Contoso Production" }, - { id: "11111111-1111-1111-1111-111111111111", name: "Contoso Dev/Test" }, -]; - -// /api/gateways?subscriptionId=... -> { gateways: [{ id, name, location }], hasMore } -// The client splits id on "/" and reads the segment after "resourceGroups", -// so the id must contain a resourceGroups segment. -export const gateways = [ - { - id: "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-connectors/providers/Microsoft.ConnectorNamespaces/connectorNamespaces/contoso-ns", - name: "contoso-ns", - location: "eastus", - }, - { - id: "/subscriptions/00000000-0000-0000-0000-000000000001/resourceGroups/rg-shared/providers/Microsoft.ConnectorNamespaces/connectorNamespaces/shared-ns", - name: "shared-ns", - location: "westus2", - }, -]; - -// Active namespace shown in the catalog header (config.gatewayName / resourceGroup). -export const config = { - subscriptionId: "00000000-0000-0000-0000-000000000001", - gatewayName: "contoso-ns", - resourceGroup: "rg-connectors", -}; - -// Catalog tiles. Shape per item: { category, displayName, apiName, description, -// iconUri?, brandColor? }. At least one item must be connectable so the -// connect -> spinner flow can be exercised. -// -// The renderer routes items by category: exactly `category === CATEGORY.microsoft` -// lands in the Microsoft section, everything else in Partners. Keep a mix of -// both here so the preview exercises the full 3-section layout (My MCPs / -// Microsoft / Partners) rather than dumping every tile into one section. -export const catalog = [ - { - category: CATEGORY.microsoft, - displayName: "Microsoft Teams", - apiName: "teams", - description: "Send messages, manage chats and channels.", - brandColor: "#5059c9", - }, - { - category: CATEGORY.microsoft, - displayName: "Outlook Mail", - apiName: "outlook", - description: "Read, send, and organize email.", - brandColor: "#0a66c2", - }, - { - category: CATEGORY.microsoft, - displayName: "SharePoint", - apiName: "sharepoint", - description: "Browse sites, lists, and documents.", - brandColor: "#038387", - }, - { - category: CATEGORY.partner, - displayName: "GitHub", - apiName: "github", - description: "Manage repos, issues, and pull requests.", - brandColor: "#24292e", - }, - { - category: CATEGORY.partner, - displayName: "Stripe", - apiName: "stripe", - description: "Payments, customers, and invoices.", - brandColor: "#635bff", - }, -]; - -// /api/state -> { state: { apiName: InstallState }, pendingRestart } -// InstallState: { installed, connectionStatus, inCli, cliPath?, cliScope? } -// Default state: nothing installed, no pending restart. The catalog renders -// every tile with a "Connect" button. -export const stateEmpty = { - state: {}, - pendingRestart: false, -}; - -// One connector already added (shows "Added" + Remove), restart pending so the -// banner is visible on load. Drives both the "added" tile and the banner state. -export const stateInstalledRestart = { - state: { - sharepoint: { - installed: true, - connectionStatus: "Connected", - inCli: true, - cliPath: "~/.copilot/mcp-config.json", - cliScope: "profile", - }, - }, - pendingRestart: true, -}; - -// Install response that forces the connecting flow. needsConsent keeps the -// sign-in modal (with the .si-spin spinner) open; /oauth-status then stays -// pending so the spinner keeps animating for a screenshot. -export const installNeedsConsent = { - needsConsent: true, - connName: "preview-conn", - consentUrl: "http://127.0.0.1:7331/fake-consent", - location: "eastus", -}; diff --git a/extensions/connector-namespaces/preview/server.mjs b/extensions/connector-namespaces/preview/server.mjs deleted file mode 100644 index d56a14c60..000000000 --- a/extensions/connector-namespaces/preview/server.mjs +++ /dev/null @@ -1,151 +0,0 @@ -// Standalone preview server for the connector-namespaces connector catalog. -// -// Renders every canvas state with no Copilot app, no ARM, and no real OAuth by -// importing the *pure* HTML builders from renderer.mjs and stubbing every -// /api/* endpoint the inline client script calls. Point any browser (or the -// agent-browser driver in shots.mjs) at it to see exactly what ships. -// -// Run: node extensions/connector-namespaces/preview/server.mjs -// Then open http://127.0.0.1:7331/ (catalog), /setup, /error. -// -// This process is NOT the JSON-RPC extension provider, so console.log here is -// fine and intentional — it is how you watch which stubbed endpoints get hit. - -import { createServer } from "node:http"; - -import { - renderCatalogHtml, - renderSetupHtml, - renderErrorHtml, -} from "../renderer.mjs"; -import * as fixtures from "./fixtures.mjs"; - -const HOST = "127.0.0.1"; -const PORT = 7331; -const INSTANCE = "preview"; - -// Whatever /api/state should report next. The catalog route updates this from -// its query flags so a page load can force the banner / "added" tile on, and a -// real Connect click flips pendingRestart on via showRestartBanner(). -let activeState = fixtures.stateEmpty; - -function selectState(query) { - const restart = query.get("restart") === "1"; - const installed = query.get("installed") === "1"; - if (restart && installed) return fixtures.stateInstalledRestart; - if (installed) return { state: fixtures.stateInstalledRestart.state, pendingRestart: false }; - if (restart) return { state: {}, pendingRestart: true }; - return fixtures.stateEmpty; -} - -function sendHtml(res, body) { - res.setHeader("Content-Type", "text/html; charset=utf-8"); - res.end(body); -} - -function sendJson(res, obj, status = 200) { - res.statusCode = status; - res.setHeader("Content-Type", "application/json"); - res.end(JSON.stringify(obj)); -} - -async function readBody(req) { - const chunks = []; - for await (const chunk of req) chunks.push(chunk); - if (!chunks.length) return {}; - try { - return JSON.parse(Buffer.concat(chunks).toString("utf8")); - } catch { - return {}; - } -} - -const server = createServer(async (req, res) => { - const url = new URL(req.url, `http://${HOST}:${PORT}`); - const path = url.pathname; - const q = url.searchParams; - // Strip CR/LF/tab so a crafted request line can't forge extra log entries. - console.log(`${req.method} ${req.url}`.replace(/[\r\n\t]/g, " ")); - - // --- Page routes --------------------------------------------------------- - if (req.method === "GET" && (path === "/" || path === "/catalog")) { - activeState = selectState(q); - return sendHtml( - res, - renderCatalogHtml(INSTANCE, fixtures.catalog, { - filter: q.get("filter") || "", - category: q.get("category") || "all", - source: q.get("source") || "", - config: fixtures.config, - }), - ); - } - if (req.method === "GET" && path === "/setup") { - return sendHtml(res, renderSetupHtml(fixtures.subscriptions)); - } - if (req.method === "GET" && path === "/error") { - return sendHtml(res, renderErrorHtml(q.get("message") || "Something went wrong loading connectors.")); - } - if (req.method === "GET" && path === "/fake-consent") { - return sendHtml(res, "ConsentFake Microsoft consent page (preview). Close this tab."); - } - - // --- Stubbed API endpoints ---------------------------------------------- - if (req.method === "GET" && path === "/api/state") { - return sendJson(res, activeState); - } - if (req.method === "GET" && path === "/api/gateways") { - return sendJson(res, { gateways: fixtures.gateways, hasMore: false }); - } - if (req.method === "GET" && path === "/oauth-status") { - // Stay pending forever so the connecting spinner keeps animating for a - // screenshot. Flip to { done: true } if you want the full success flow. - return sendJson(res, { done: false }); - } - - if (req.method === "POST") { - await readBody(req); - switch (path) { - case "/api/select-gateway": - return sendJson(res, { ok: true }); - case "/api/install": - return sendJson(res, fixtures.installNeedsConsent); - case "/api/finish-install": - activeState = { ...activeState, pendingRestart: true }; - return sendJson(res, { ok: true }); - case "/api/ack-restart": - activeState = { ...activeState, pendingRestart: false }; - return sendJson(res, { ok: true }); - case "/api/uninstall": - return sendJson(res, { ok: true }); - case "/api/open-url": - // Preview no-op: do NOT actually launch a browser tab. - return sendJson(res, { ok: true }); - case "/api/rollback-connection": - return sendJson(res, { ok: true }); - default: - return sendJson(res, { error: `unstubbed POST ${path}` }, 404); - } - } - - res.statusCode = 404; - res.end("not found"); -}); - -server.on("error", (err) => { - if (err.code === "EADDRINUSE") { - console.error(`Port ${PORT} is already in use. Stop the other process or change PORT in server.mjs.`); - process.exit(1); - } - throw err; -}); - -server.listen(PORT, HOST, () => { - console.log(`canvas preview server: http://${HOST}:${PORT}/`); - console.log(" / catalog (empty state)"); - console.log(" /?restart=1 catalog with restart banner visible"); - console.log(" /?installed=1 catalog with one connector added"); - console.log(" /setup namespace picker"); - console.log(" /error error state"); - console.log("Press Ctrl+C to stop."); -}); diff --git a/extensions/connector-namespaces/preview/shots.mjs b/extensions/connector-namespaces/preview/shots.mjs deleted file mode 100644 index aab8f17b1..000000000 --- a/extensions/connector-namespaces/preview/shots.mjs +++ /dev/null @@ -1,96 +0,0 @@ -// agent-browser screenshot driver for the canvas preview server. -// -// Captures every canvas state to ./shots/ and drives the two interaction flows -// that keep regressing: -// 1. catalog -> click Connect -> sign-in modal with the spinning .si-spin -// 2. restart banner visible -> click dismiss -> banner gone -// -// Requires the preview server to be running: -// node extensions/connector-namespaces/preview/server.mjs -// And agent-browser installed: -// npm i -g agent-browser && agent-browser install -// -// If agent-browser is not installed, this script prints how to install it and -// exits 0 (so it never breaks an unattended run). This is a visual-evidence -// helper; the deterministic regression gate is renderer.test.mjs. - -import { spawnSync } from "node:child_process"; -import { mkdirSync } from "node:fs"; -import { dirname, join } from "node:path"; -import { fileURLToPath } from "node:url"; - -const HERE = dirname(fileURLToPath(import.meta.url)); -const SHOTS = join(HERE, "shots"); -const BASE = "http://127.0.0.1:7331"; - -function hasAgentBrowser() { - const probe = spawnSync("agent-browser", ["--version"], { encoding: "utf8", shell: true }); - return probe.status === 0; -} - -function ab(args) { - const r = spawnSync("agent-browser", args, { encoding: "utf8", shell: true }); - if (r.status !== 0) { - console.error(`agent-browser ${args.join(" ")} failed:\n${r.stderr || r.stdout}`); - } - return r; -} - -function serverUp() { - // Node 18+ has global fetch. Confirm the preview server is reachable. - return fetch(`${BASE}/api/state`).then(() => true).catch(() => false); -} - -async function main() { - if (!hasAgentBrowser()) { - console.log("agent-browser is not installed -> skipping screenshots."); - console.log("Install it with: npm i -g agent-browser && agent-browser install"); - console.log("Then re-run: node extensions/connector-namespaces/preview/shots.mjs"); - process.exit(0); - } - - if (!(await serverUp())) { - console.error("preview server is not reachable at " + BASE); - console.error("start it first: node extensions/connector-namespaces/preview/server.mjs"); - process.exit(1); - } - - mkdirSync(SHOTS, { recursive: true }); - - // Static states. - const states = [ - ["catalog", `${BASE}/`], - ["catalog-restart-banner", `${BASE}/?restart=1`], - ["catalog-installed", `${BASE}/?installed=1`], - ["setup", `${BASE}/setup`], - ["error", `${BASE}/error`], - ]; - for (const [name, target] of states) { - ab(["open", target]); - ab(["screenshot", join(SHOTS, `${name}.png`)]); - console.log(`captured ${name}`); - } - - // Flow 1: connect -> connecting spinner. The preview /api/install returns - // needsConsent and /oauth-status stays pending, so the .si-spin modal - // spinner keeps animating. Best-effort selector; adjust if markup changes. - ab(["open", `${BASE}/`]); - ab(["click", ".item-add[data-api]"]); - ab(["screenshot", join(SHOTS, "connecting-spinner.png")]); - console.log("captured connecting-spinner (verify the spinner is mid-rotation)"); - - // Flow 2: banner -> dismiss -> gone. Screenshot before and after the click - // so a frozen/broken dismiss button is visible as a diff. - ab(["open", `${BASE}/?restart=1`]); - ab(["screenshot", join(SHOTS, "banner-before-dismiss.png")]); - ab(["click", ".restart-banner .rb-dismiss"]); - ab(["screenshot", join(SHOTS, "banner-after-dismiss.png")]); - console.log("captured banner-before-dismiss / banner-after-dismiss (after should have no banner)"); - - console.log(`\nshots written to ${SHOTS}`); -} - -main().catch((err) => { - console.error(err); - process.exit(1); -}); diff --git a/extensions/connector-namespaces/renderer.mjs b/extensions/connector-namespaces/renderer.mjs deleted file mode 100644 index b2a74c6a1..000000000 --- a/extensions/connector-namespaces/renderer.mjs +++ /dev/null @@ -1,1762 +0,0 @@ -// Renderers for the connector namespace picker and connector catalog pages. -// Styled to match the reference connector extension UI. - -import { CATEGORY } from "./categories.mjs"; -import { buildSandboxUrl } from "./sandbox.mjs"; - -const CONNECT_ICON = ''; - -// Official Azure Connector Namespace mark — a gray viewfinder frame wrapping -// two interlocking blue-gradient chain links. Path + gradient data is lifted -// verbatim from the portal's ConnectorNamespaceIcon brand asset. idSuffix keeps -// the gradient element IDs unique when the mark renders more than once per page. -export function brandMark(size = 28, idSuffix = "m") { - const g0 = `cn-g0-${idSuffix}`; - const g1 = `cn-g1-${idSuffix}`; - return ``; -} - -export function baseStyles() { - return ``; -} - -// --------------------------------------------------------------------------- -// Setup / Namespace Picker -// --------------------------------------------------------------------------- - -export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken = "", { linkedNamespace = "" } = {}) { - const hasLinkedNamespace = typeof linkedNamespace === "string" && linkedNamespace.length > 0; - const pageTitle = hasLinkedNamespace ? "Sign in to MCP Connectors" : "Select Connector Namespace"; - const heading = hasLinkedNamespace ? "Sign in to see your connectors" : "Select a Connector Namespace"; - const subheading = hasLinkedNamespace - ? `Connector namespace ${esc(linkedNamespace)} is already linked.` - : "Choose which connector namespace to browse. This choice is saved for future sessions."; - const defaultSigninMessage = hasLinkedNamespace - ? "Sign in to Azure to view and manage its connectors." - : "Sign in to Azure to load your subscriptions and connector namespaces."; - const subOptions = subscriptions.map((s) => - `` - ).join(""); - - return ` - -${pageTitle}${baseStyles()} - -
-

${brandMark(30, "setup")}${heading}

-
${subheading}
-
-${notice ? `
${esc(notice)}
` : ""} - -
-
- -
-
- - -
- -
-
${subscriptions.length ? "Select a subscription to see available connector namespaces." : "Loading subscriptions\u2026"}
-
-
-`; -} - -// --------------------------------------------------------------------------- -// Catalog -// --------------------------------------------------------------------------- - -const CSS_HEX_COLOR = /^#[0-9a-fA-F]{6}$/; - -function iconBackgroundStyle(brandColor) { - const color = String(brandColor || "").trim(); - return CSS_HEX_COLOR.test(color) ? ` style="background:${color}22"` : ""; -} - -export function renderCatalogHtml(instanceId, catalog, { filter, category, source, config }, capabilityToken = "") { - const renderItem = (c) => { - // Items carry their home grid so hydrateState can move them into - // "My MCPs" when added and back to Microsoft/Partner on remove. - const home = c.category === CATEGORY.microsoft ? "microsoft" : "partner"; - const icon = c.iconUri - ? `
` - : `
${esc(c.displayName.charAt(0))}
`; - // Button state is hydrated client-side from /api/state on load. - const btn = ``; - const haystack = esc((c.displayName + " " + (c.description || "")).toLowerCase()); - const sandboxUrl = esc(buildSandboxUrl(config, c.apiName)); - return `
${icon}
${esc(c.displayName)}
${esc(c.description)}
${btn}
`; - }; - - const byName = (a, b) => a.displayName.localeCompare(b.displayName); - const microsoft = catalog.filter((c) => c.category === CATEGORY.microsoft).sort(byName); - const partner = catalog.filter((c) => c.category !== CATEGORY.microsoft).sort(byName); - - const section = (key, title, rows, { collapsed, hidden }) => { - const cls = ["section", "collapsible"]; - if (collapsed) cls.push("collapsed"); - if (hidden) cls.push("is-hidden"); - const n = rows.length; - return `
` - + `` - + `
${rows.map(renderItem).join("")}
` - + `
`; - }; - - // Server paints the first-run layout: My MCPs hidden+empty (filled by - // hydrateState), Microsoft expanded so there's something to browse, Partner - // collapsed. updateSections() flips to the steady layout on the first hydrate - // if anything is already added. - let sectionsHtml = - section("mine", "My MCPs", [], { collapsed: false, hidden: true }) + - section("microsoft", "Microsoft", microsoft, { collapsed: false, hidden: microsoft.length === 0 }) + - section("partner", "Partners", partner, { collapsed: true, hidden: partner.length === 0 }); - - if (!catalog.length) { - sectionsHtml = `
No connectors available.
`; - } - - return ` - -Connectors${baseStyles()} - -
-
-

${brandMark(24, "cat")}Connectors

-
-
Namespace ${esc(config.gatewayName)} · RG ${esc(config.resourceGroup)}
-
- - - -
-
-
- - - - -
- -${sectionsHtml} - -`; -} - -// --------------------------------------------------------------------------- -// Error -// --------------------------------------------------------------------------- - -export function renderErrorHtml(message) { - return ` -Error${baseStyles()} -

Error

-
${esc(message)}
-`; -} - -// --------------------------------------------------------------------------- -function esc(s) { - return String(s ?? "").replace(/[&<>"']/g, (c) => ({"&":"&","<":"<",">":">",'"':""","'":"'"}[c])); -} diff --git a/extensions/connector-namespaces/renderer.test.mjs b/extensions/connector-namespaces/renderer.test.mjs deleted file mode 100644 index fbb57e524..000000000 --- a/extensions/connector-namespaces/renderer.test.mjs +++ /dev/null @@ -1,447 +0,0 @@ -// Regression guards for the connector-catalog renderer. -// -// Run: node --test extensions/connector-namespaces/renderer.test.mjs -// -// These tests exist because two UX bugs kept coming back: -// 1. A `@media (prefers-reduced-motion: reduce)` rule froze functional -// loaders without a visible fallback. Reduced motion now stops the -// animation while forcing each loader into a visible static busy state; -// nearby text continues to communicate progress. -// 2. The "Restart the GitHub Copilot app" banner ignoring Dismiss. The real -// root cause was CSS specificity: `.restart-banner{display:flex}` is an -// author rule with the same (0,1,0) specificity as the UA -// `[hidden]{display:none}` rule, so it overrode the hidden attribute and -// `restartBanner.hidden=true` did nothing. The fix is a global -// `[hidden]{display:none !important}` reset. A client-side -// `restartDismissed` flag also keeps a late hydrateState() from re-showing -// it. The guards below fail if either the CSS reset or the JS gate -// disappears. - -import { test } from "node:test"; -import assert from "node:assert/strict"; - -import { baseStyles, renderCatalogHtml, renderSetupHtml } from "./renderer.mjs"; -import { renderCreateNamespaceHtml } from "./createPage.mjs"; -import { CATEGORY } from "./categories.mjs"; - -// Pull the balanced body of the prefers-reduced-motion media block out of a -// stylesheet string (non-greedy regex can't handle the nested rule braces). -// CSS comments are stripped so the guards test declarations rather than prose. -function reducedMotionBlock(css) { - const start = css.indexOf("@media (prefers-reduced-motion: reduce)"); - if (start === -1) return null; - const open = css.indexOf("{", start); - if (open === -1) return null; - let depth = 0; - for (let i = open; i < css.length; i++) { - if (css[i] === "{") depth++; - else if (css[i] === "}" && --depth === 0) { - return css.slice(open + 1, i).replace(/\/\*[\s\S]*?\*\//g, ""); - } - } - return null; -} - -function catalogHtml() { - return renderCatalogHtml("test-instance", [], { - filter: "", - category: "all", - source: "", - config: { subscriptionId: "sub", gatewayName: "ns", resourceGroup: "rg" }, - }); -} - -test("setup subscription label names its select", () => { - const html = renderSetupHtml([], "", "token"); - assert.match(html, /