From 191309165e54ae1aab978fbdc01c727bce511694 Mon Sep 17 00:00:00 2001 From: "Alex Yang [MSFT]" Date: Wed, 15 Jul 2026 17:29:48 -0700 Subject: [PATCH] =?UTF-8?q?Add=20Azure=20Connector=20Namespaces=20canvas?= =?UTF-8?q?=20extension=20=F0=9F=A4=96=F0=9F=A4=96=F0=9F=A4=96=20(#2250)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Add MCP Connectors (connector-namespaces) canvas extension A Copilot CLI canvas extension for browsing and adding MCP connectors from an Azure Connector Namespace into a Copilot session. Sign-in is dependency-free (OAuth 2.0 auth-code + PKCE via the Azure CLI public client, loopback redirect); network access is restricted to the public Azure Resource Manager endpoint. MIT licensed. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Update Connector Namespaces canvas extension Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Harden Connector Namespaces canvas extension Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Sanitize connector icon brand colors Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Add Connector Namespace playground actions Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Replace sandbox skill with native tool Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Clarify connector disconnect action Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Color disconnect action red Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Add connector extension plugin manifest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Address connector canvas review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Remove legacy connector canvas manifest Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Address remaining connector review feedback Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Harden connector review fixes Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Harden connector convergence Address the latest connector review batch across config persistence, executable trust, reauthentication, JSON-RPC transport, smoke safety, and accessibility. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Use native HTTP connector configs Persist Connector Namespace MCP servers as direct HTTPS entries with API-key headers, remove the stdio unwrap proxy, and exercise the native Streamable HTTP path in smoke coverage. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf * Secure persisted connector state Create the Connector Namespace artifacts directory and saved gateway config with private permissions, and align the reduced-motion regression notes with the static fallback behavior. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: a986299f-86be-46c3-9562-cbf7d25174cf --------- Co-authored-by: Alex Yang Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../.github/plugin/plugin.json | 19 + extensions/connector-namespaces/LICENSE | 21 + extensions/connector-namespaces/README.md | 85 + extensions/connector-namespaces/armClient.mjs | 444 +++++ .../assets/preview-connected.png | Bin 0 -> 22914 bytes .../connector-namespaces/assets/preview.png | Bin 0 -> 32685 bytes extensions/connector-namespaces/catalog.mjs | 70 + .../connector-namespaces/categories.mjs | 15 + .../copilot-extension.json | 4 + .../connector-namespaces/createPage.mjs | 403 +++++ extensions/connector-namespaces/extension.mjs | 101 ++ extensions/connector-namespaces/install.mjs | 1007 +++++++++++ .../install.reauth.test.mjs | 558 ++++++ .../connector-namespaces/install.test.mjs | 253 +++ .../mcp-http-probe.test.mjs | 58 + extensions/connector-namespaces/package.json | 19 + .../connector-namespaces/preview/.gitignore | 2 + .../connector-namespaces/preview/README.md | 112 ++ .../connector-namespaces/preview/fixtures.mjs | 117 ++ .../connector-namespaces/preview/server.mjs | 151 ++ .../connector-namespaces/preview/shots.mjs | 96 + extensions/connector-namespaces/renderer.mjs | 1563 +++++++++++++++++ .../connector-namespaces/renderer.test.mjs | 393 +++++ .../review-fixes.test.mjs | 139 ++ extensions/connector-namespaces/sandbox.mjs | 49 + .../connector-namespaces/sandbox.test.mjs | 54 + extensions/connector-namespaces/server.mjs | 616 +++++++ .../connector-namespaces/server.test.mjs | 215 +++ extensions/connector-namespaces/state.mjs | 115 ++ .../connector-namespaces/state.test.mjs | 32 + .../connector-namespaces/test/README.md | 128 ++ .../connector-namespaces/test/mcp-probe.mjs | 173 ++ .../connector-namespaces/test/safe-tools.mjs | 168 ++ .../test/safe-tools.test.mjs | 150 ++ .../connector-namespaces/test/smoke.mjs | 328 ++++ 35 files changed, 7658 insertions(+) create mode 100644 extensions/connector-namespaces/.github/plugin/plugin.json create mode 100644 extensions/connector-namespaces/LICENSE create mode 100644 extensions/connector-namespaces/README.md create mode 100644 extensions/connector-namespaces/armClient.mjs create mode 100644 extensions/connector-namespaces/assets/preview-connected.png create mode 100644 extensions/connector-namespaces/assets/preview.png create mode 100644 extensions/connector-namespaces/catalog.mjs create mode 100644 extensions/connector-namespaces/categories.mjs create mode 100644 extensions/connector-namespaces/copilot-extension.json create mode 100644 extensions/connector-namespaces/createPage.mjs create mode 100644 extensions/connector-namespaces/extension.mjs create mode 100644 extensions/connector-namespaces/install.mjs create mode 100644 extensions/connector-namespaces/install.reauth.test.mjs create mode 100644 extensions/connector-namespaces/install.test.mjs create mode 100644 extensions/connector-namespaces/mcp-http-probe.test.mjs create mode 100644 extensions/connector-namespaces/package.json create mode 100644 extensions/connector-namespaces/preview/.gitignore create mode 100644 extensions/connector-namespaces/preview/README.md create mode 100644 extensions/connector-namespaces/preview/fixtures.mjs create mode 100644 extensions/connector-namespaces/preview/server.mjs create mode 100644 extensions/connector-namespaces/preview/shots.mjs create mode 100644 extensions/connector-namespaces/renderer.mjs create mode 100644 extensions/connector-namespaces/renderer.test.mjs create mode 100644 extensions/connector-namespaces/review-fixes.test.mjs create mode 100644 extensions/connector-namespaces/sandbox.mjs create mode 100644 extensions/connector-namespaces/sandbox.test.mjs create mode 100644 extensions/connector-namespaces/server.mjs create mode 100644 extensions/connector-namespaces/server.test.mjs create mode 100644 extensions/connector-namespaces/state.mjs create mode 100644 extensions/connector-namespaces/state.test.mjs create mode 100644 extensions/connector-namespaces/test/README.md create mode 100644 extensions/connector-namespaces/test/mcp-probe.mjs create mode 100644 extensions/connector-namespaces/test/safe-tools.mjs create mode 100644 extensions/connector-namespaces/test/safe-tools.test.mjs create mode 100644 extensions/connector-namespaces/test/smoke.mjs diff --git a/extensions/connector-namespaces/.github/plugin/plugin.json b/extensions/connector-namespaces/.github/plugin/plugin.json new file mode 100644 index 00000000..18b8c116 --- /dev/null +++ b/extensions/connector-namespaces/.github/plugin/plugin.json @@ -0,0 +1,19 @@ +{ + "name": "connector-namespaces", + "description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.", + "version": "1.1.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 new file mode 100644 index 00000000..22aed37e --- /dev/null +++ b/extensions/connector-namespaces/LICENSE @@ -0,0 +1,21 @@ +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 new file mode 100644 index 00000000..f7346247 --- /dev/null +++ b/extensions/connector-namespaces/README.md @@ -0,0 +1,85 @@ +# MCP Connectors — Copilot CLI Canvas Extension + +A GitHub Copilot CLI **canvas extension** that lets you browse and add MCP +connectors from an Azure **Connector Namespace** directly inside a Copilot CLI +session. Search by name or category, sign in to a connector, then restart the +session to make its tools available to the agent. + +> The canvas talks to public Azure Resource Manager (`management.azure.com`) +> using the signed-in Azure CLI account. The extension does not register its own +> Entra application or persist Azure credentials. + +## Prerequisites + +- **GitHub Copilot CLI** (the host that loads canvas extensions). +- **Azure CLI**, signed in with `az login`. The extension asks Azure CLI for a + short-lived ARM access token and refreshes it through the same broker. +- **An Azure subscription with a Connector Namespace** — resource type + `Microsoft.Web/connectorGateways` (API version `2026-05-01-preview`). This is + a preview resource provider; you must have access to it for the catalog to + load. Without it the extension installs fine but has nothing to show. + +## Install + +Install it from the public Awesome Copilot repository: + +``` +install_extension https://github.com/github/awesome-copilot/tree/main/extensions/connector-namespaces +``` + +For a reproducible install, swap `main` for a reviewed commit SHA from this +repository. + +The destination **scope** is chosen at install time: + +- **user** (default) — installs globally for you at + `$COPILOT_HOME/extensions/connector-namespaces/`. The usual choice for a + personal tool. +- **project** — installs into the current repo. +- **session** — scoped to a single CLI session. + +## Usage + +1. Open the **MCP Connectors** canvas from Copilot CLI. +2. The canvas loads subscriptions from your signed-in Azure CLI account. Pick an + Azure **subscription** and a **Connector Namespace**. The choice is saved for + future sessions (change it any time via **Change namespace**). +3. Browse or filter the connector catalog, then **Connect**. A browser tab + opens for Microsoft sign-in; complete it and the canvas updates on its own. +4. Connected connectors move into **My MCPs**. Use **Sandbox** on a tile to open + that server directly in the namespace MCP playground. +5. Restart the Copilot CLI session so the agent can load the connected tools. + +The extension registers the native `connector_namespaces_open_playground` tool, +so GitHub Copilot can open a named connector from **My MCPs** without installing +an additional Agent Skill. + +## How it works + +- `extension.mjs` — entry point; declares the canvas, `open_sandbox` action, and + native `connector_namespaces_open_playground` tool. +- `server.mjs` — a loopback HTTP server (bound to `127.0.0.1` only) that serves + the canvas UI and the JSON/OAuth endpoints the iframe calls. +- `armClient.mjs` — thin ARM client (token brokered by Azure CLI, public ARM + base only, SSRF-guarded path segments). +- `catalog.mjs` — fetches and curates the connector list for a namespace. +- `install.mjs` — the connect/install pipeline (managed-API connection, consent, + rollback on cancel, and native HTTPS MCP config registration). +- `renderer.mjs` — all canvas HTML/CSS/client JS. +- `sandbox.mjs` — builds namespace playground links and resolves named My MCPs. +- `state.mjs` — saved namespace and connector state. + +## Privacy & security + +- ARM tokens come from `az account get-access-token`, stay in process memory, + and are never logged or written by the extension. Azure CLI owns sign-in and + credential storage. +- All servers bind to loopback (`127.0.0.1`) and are never exposed externally. +- ARM requests go only to `https://management.azure.com/`; path segments are + validated to prevent SSRF-style host smuggling. +- The minted gateway API key is stored in the selected Copilot MCP config and + sent to its validated HTTPS endpoint as the `X-API-Key` header. + +## License + +[MIT](./LICENSE) © Microsoft Corporation. diff --git a/extensions/connector-namespaces/armClient.mjs b/extensions/connector-namespaces/armClient.mjs new file mode 100644 index 00000000..dc0f9fc4 --- /dev/null +++ b/extensions/connector-namespaces/armClient.mjs @@ -0,0 +1,444 @@ +// ARM API client — fetches real connector data with Azure CLI credentials. + +import { exec, execFile } from "node:child_process"; +import { constants as fsConstants, promises as fs } from "node:fs"; +import { homedir, platform } from "node:os"; +import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path"; +import { promisify } from "node:util"; + +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"; + +const execFileAsync = promisify(execFile); +const execAsync = promisify(exec); +const ARM_RESOURCE = "https://management.azure.com/"; +const EXPIRY_SKEW_MS = 5 * 60 * 1000; +const LEGACY_AUTH_CACHE = join( + process.env.COPILOT_HOME || join(homedir(), ".copilot"), + "extensions", + "connector-namespaces", + "artifacts", + "auth-cache.json", +); + +let s_auth = null; // { token, expiresAt } +let s_authInFlight = null; +let s_legacyAuthCacheRemoved = false; + +export function parseAzureCliToken(stdout) { + let data; + try { + data = JSON.parse(stdout); + } catch { + throw new Error("Azure CLI returned invalid token JSON."); + } + const token = data?.accessToken; + const epochSeconds = Number(data?.expires_on); + const expiresAt = Number.isFinite(epochSeconds) && epochSeconds > 0 + ? epochSeconds * 1000 + : Date.parse(data?.expiresOn); + if (typeof token !== "string" || token.length === 0 || !Number.isFinite(expiresAt)) { + throw new Error("Azure CLI returned an incomplete ARM token."); + } + return { token, expiresAt }; +} + +async function removeLegacyAuthCache() { + if (s_legacyAuthCacheRemoved) return; + try { + await fs.unlink(LEGACY_AUTH_CACHE); + } catch (error) { + if (error?.code !== "ENOENT") { + throw new Error(`Could not remove the legacy connector credential cache at ${LEGACY_AUTH_CACHE}: ${error.message}`); + } + } + s_legacyAuthCacheRemoved = true; +} + +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; +} + +async function resolveWindowsAzureCli() { + const trustedCwd = homedir(); + const { stdout } = await execFileAsync( + windowsSystemExecutable("where.exe"), + ["az.cmd"], + { cwd: trustedCwd, encoding: "utf8", windowsHide: true, timeout: 10_000, maxBuffer: 64 * 1024 }, + ); + for (const path of stdout.split(/\r?\n/).map((line) => line.trim())) { + if (/[%]/.test(path)) continue; + const candidate = await trustedExecutablePath(path, "az.cmd"); + if (candidate) return candidate; + } + throw new Error("Azure CLI was not found outside the current workspace."); +} + +export async function resolvePosixAzureCli(pathValue = process.env.PATH || "", workspaceRoot = process.cwd()) { + for (const directory of pathValue.split(delimiter)) { + const candidate = await trustedExecutablePath(resolve(directory || workspaceRoot, "az"), "az", workspaceRoot); + if (candidate) return candidate; + } + throw new Error("Azure CLI was not found outside the current workspace."); +} + +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}.`); +} + +async function acquireToken() { + await removeLegacyAuthCache(); + try { + const windows = platform() === "win32"; + const azureCli = windows ? await resolveWindowsAzureCli() : await resolvePosixAzureCli(); + const options = { cwd: homedir(), encoding: "utf8", windowsHide: true, timeout: 60_000, maxBuffer: 1024 * 1024 }; + const { stdout } = windows + ? await execAsync( + `"${azureCli}" account get-access-token --resource https://management.azure.com/ --output json --only-show-errors`, + { ...options, shell: windowsSystemExecutable("cmd.exe") }, + ) + : await execFileAsync( + azureCli, + ["account", "get-access-token", "--resource", ARM_RESOURCE, "--output", "json", "--only-show-errors"], + options, + ); + s_auth = parseAzureCliToken(stdout); + return s_auth.token; + } catch (error) { + const detail = String(error?.stderr || error?.message || "").trim(); + throw new Error( + `Azure CLI authentication failed. Install Azure CLI and run "az login" before opening the canvas.${detail ? ` ${detail}` : ""}`, + ); + } +} + +export async function getToken() { + if (s_auth && s_auth.expiresAt - EXPIRY_SKEW_MS > Date.now()) return s_auth.token; + if (s_authInFlight) return s_authInFlight; + s_authInFlight = acquireToken().finally(() => { + s_authInFlight = null; + }); + return s_authInFlight; +} + +/** + * 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 async function listSubscriptions() { + const now = Date.now(); + if (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 new file mode 100644 index 0000000000000000000000000000000000000000..4050d8787eb92152429a831f3f0ca06176ff74d1 GIT binary patch literal 22914 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 av{%Fg!nM3x7b6c`5wSp7n-n75@BRn&z>mZL literal 0 HcmV?d00001 diff --git a/extensions/connector-namespaces/assets/preview.png b/extensions/connector-namespaces/assets/preview.png new file mode 100644 index 0000000000000000000000000000000000000000..259e27941361c56176347838b4c592b18bff62d3 GIT binary patch literal 32685 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 E11p{EHvj+t literal 0 HcmV?d00001 diff --git a/extensions/connector-namespaces/catalog.mjs b/extensions/connector-namespaces/catalog.mjs new file mode 100644 index 00000000..79f4165c --- /dev/null +++ b/extensions/connector-namespaces/catalog.mjs @@ -0,0 +1,70 @@ +// 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 new file mode 100644 index 00000000..57cd9592 --- /dev/null +++ b/extensions/connector-namespaces/categories.mjs @@ -0,0 +1,15 @@ +// 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 new file mode 100644 index 00000000..3949dbe4 --- /dev/null +++ b/extensions/connector-namespaces/copilot-extension.json @@ -0,0 +1,4 @@ +{ + "name": "connector-namespaces", + "version": 1 +} diff --git a/extensions/connector-namespaces/createPage.mjs b/extensions/connector-namespaces/createPage.mjs new file mode 100644 index 00000000..3eb86659 --- /dev/null +++ b/extensions/connector-namespaces/createPage.mjs @@ -0,0 +1,403 @@ +// 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 new file mode 100644 index 00000000..fe6f502e --- /dev/null +++ b/extensions/connector-namespaces/extension.mjs @@ -0,0 +1,101 @@ +// 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: "Browse, connect, and open MCP connectors in the Azure Connector Namespace Sandbox.", + 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 new file mode 100644 index 00000000..74264d6f --- /dev/null +++ b/extensions/connector-namespaces/install.mjs @@ -0,0 +1,1007 @@ +// 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 new file mode 100644 index 00000000..50da0470 --- /dev/null +++ b/extensions/connector-namespaces/install.reauth.test.mjs @@ -0,0 +1,558 @@ +// 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 { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; +import { delimiter, 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. Put a fake Azure CLI on PATH so getToken() stays 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 binDir = join(TMP, "bin"); +mkdirSync(binDir, { recursive: true }); +const tokenJson = JSON.stringify({ accessToken: "fake-token", expires_on: Math.floor(Date.now() / 1000) + 3600 }); +writeFileSync(join(binDir, "az"), `#!/usr/bin/env node\nprocess.stdout.write(${JSON.stringify(tokenJson)});\n`); +chmodSync(join(binDir, "az"), 0o755); +writeFileSync(join(binDir, "az.cmd"), `@echo ${tokenJson}\r\n`); +process.env.PATH = `${binDir}${delimiter}${process.env.PATH || ""}`; + +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" } } }), +); + +// 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 new file mode 100644 index 00000000..5e51de54 --- /dev/null +++ b/extensions/connector-namespaces/install.test.mjs @@ -0,0 +1,253 @@ +// 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 new file mode 100644 index 00000000..009f1086 --- /dev/null +++ b/extensions/connector-namespaces/mcp-http-probe.test.mjs @@ -0,0 +1,58 @@ +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 new file mode 100644 index 00000000..ac0c28b0 --- /dev/null +++ b/extensions/connector-namespaces/package.json @@ -0,0 +1,19 @@ +{ + "name": "connector-namespaces", + "version": "1.1.0", + "type": "module", + "main": "extension.mjs", + "description": "Browse, connect, and open MCP connectors from your Azure Connector Namespace in Sandbox.", + "keywords": [ + "azure", + "connector-namespace", + "mcp", + "mcp-connectors", + "model-context-protocol", + "tool-discovery" + ], + "license": "MIT", + "dependencies": { + "@github/copilot-sdk": "1.0.6" + } +} diff --git a/extensions/connector-namespaces/preview/.gitignore b/extensions/connector-namespaces/preview/.gitignore new file mode 100644 index 00000000..0bac4c45 --- /dev/null +++ b/extensions/connector-namespaces/preview/.gitignore @@ -0,0 +1,2 @@ +# 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 new file mode 100644 index 00000000..14b839b1 --- /dev/null +++ b/extensions/connector-namespaces/preview/README.md @@ -0,0 +1,112 @@ +# 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 your Copilot session…" 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 new file mode 100644 index 00000000..0fce100b --- /dev/null +++ b/extensions/connector-namespaces/preview/fixtures.mjs @@ -0,0 +1,117 @@ +// 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 new file mode 100644 index 00000000..d56a14c6 --- /dev/null +++ b/extensions/connector-namespaces/preview/server.mjs @@ -0,0 +1,151 @@ +// 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 new file mode 100644 index 00000000..aab8f17b --- /dev/null +++ b/extensions/connector-namespaces/preview/shots.mjs @@ -0,0 +1,96 @@ +// 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 new file mode 100644 index 00000000..e4ddf609 --- /dev/null +++ b/extensions/connector-namespaces/renderer.mjs @@ -0,0 +1,1563 @@ +// 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 = "") { + const subOptions = subscriptions.map((s) => + `` + ).join(""); + + return ` + +Select Connector Namespace${baseStyles()} + +
+

${brandMark(30, "setup")}Select a Connector Namespace

+
Choose which connector namespace to browse. This choice is saved for future sessions.
+
+${notice ? `
${esc(notice)}
` : ""} +
+ +
+
+ + +
+ +
+
Select a subscription to see available connector namespaces.
+
+`; +} + +// --------------------------------------------------------------------------- +// 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 new file mode 100644 index 00000000..d0c464e4 --- /dev/null +++ b/extensions/connector-namespaces/renderer.test.mjs @@ -0,0 +1,393 @@ +// 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 your Copilot session" 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, /