mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-16 10:53:25 +00:00
191309165e
* 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 <yangalex@microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
445 lines
18 KiB
JavaScript
445 lines
18 KiB
JavaScript
// 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));
|
|
}
|