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 4050d8787..000000000 Binary files a/extensions/connector-namespaces/assets/preview-connected.png and /dev/null differ diff --git a/extensions/connector-namespaces/assets/preview.png b/extensions/connector-namespaces/assets/preview.png deleted file mode 100644 index 259e27941..000000000 Binary files a/extensions/connector-namespaces/assets/preview.png and /dev/null differ 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, /