mirror of
https://github.com/github/awesome-copilot.git
synced 2026-07-21 12:54:13 +00:00
chore: publish from main
This commit is contained in:
@@ -244,8 +244,8 @@
|
||||
{
|
||||
"name": "connector-namespaces",
|
||||
"source": "extensions/connector-namespaces",
|
||||
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
|
||||
"version": "1.1.2"
|
||||
"description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.",
|
||||
"version": "1.2.0"
|
||||
},
|
||||
{
|
||||
"name": "context-engineering",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "connector-namespaces",
|
||||
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
|
||||
"version": "1.1.2",
|
||||
"description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.",
|
||||
"version": "1.2.0",
|
||||
"author": {
|
||||
"name": "Alex Yang",
|
||||
"url": "https://github.com/alexyaang"
|
||||
|
||||
@@ -1,84 +1,84 @@
|
||||
# MCP Connectors — Copilot CLI Canvas Extension
|
||||
# MCP Connectors
|
||||
|
||||
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.
|
||||
A GitHub Copilot app canvas extension for discovering and connecting hosted MCP
|
||||
servers from [Azure Connector Namespace](https://learn.microsoft.com/en-us/azure/connector-namespace/connector-namespace-overview).
|
||||
It brings the Microsoft and partner connector catalog, guided browser sign-in,
|
||||
and connected-server management into the Copilot side panel.
|
||||
|
||||
> 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.
|
||||
## Features
|
||||
|
||||
## 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.
|
||||
- **Connector catalog** - browse and search Microsoft and partner MCP servers
|
||||
available in your namespace.
|
||||
- **Guided Azure setup** - sign in from the canvas, then choose a subscription
|
||||
and Connector Namespace.
|
||||
- **Browser-based connection flow** - complete each connector's authentication
|
||||
or consent without leaving the setup experience.
|
||||
- **My MCPs** - see which servers are connected and ready to add to Copilot.
|
||||
- **Namespace playground** - open any connected server in the Connector
|
||||
Namespace playground with **Sandbox**.
|
||||
- **Persistent setup** - retain the selected namespace and restore Azure sign-in
|
||||
securely across app restarts.
|
||||
|
||||
## Install
|
||||
|
||||
Install it from the public Awesome Copilot repository:
|
||||
Open the GitHub Copilot app, go to **Settings > Plugins**, search for
|
||||
`connector-namespaces`, and select **Install**.
|
||||
|
||||
```
|
||||
install_extension https://github.com/github/awesome-copilot/tree/main/extensions/connector-namespaces
|
||||
```
|
||||
You can also open the
|
||||
[MCP Connectors gallery page](https://awesome-copilot.github.com/extension/connector-namespaces/)
|
||||
and select **Install in GitHub Copilot app**.
|
||||
|
||||
For a reproducible install, swap `main` for a reviewed commit SHA from this
|
||||
repository.
|
||||
## Requirements
|
||||
|
||||
The destination **scope** is chosen at install time:
|
||||
- Access to an Azure subscription with a Connector Namespace. If you do not
|
||||
have one, follow the
|
||||
[Connector Namespace creation guide](https://learn.microsoft.com/en-us/azure/connector-namespace/create-connector-namespace).
|
||||
- Permission to view the namespace and create its connections and hosted MCP
|
||||
server configurations.
|
||||
- A browser for Microsoft Entra sign-in and connector consent.
|
||||
- An operating-system secure credential store. Windows and macOS provide one by
|
||||
default. Linux and WSL require a Secret Service-compatible keyring, such as
|
||||
GNOME Keyring, with `libsecret` available. Unencrypted token storage is
|
||||
intentionally disabled.
|
||||
|
||||
- **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.
|
||||
Connector Namespace is currently an Azure preview service and availability can
|
||||
vary by region.
|
||||
|
||||
## 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.
|
||||
1. Open the **MCP Connectors** canvas in the GitHub Copilot app.
|
||||
2. Select **Sign in to Azure** and complete Microsoft Entra authentication in
|
||||
your browser.
|
||||
3. Choose an Azure subscription and Connector Namespace.
|
||||
4. Browse or search the catalog, then select **Connect** on an MCP server.
|
||||
5. Complete the connector's sign-in or consent flow when prompted.
|
||||
6. Confirm the server appears under **My MCPs**.
|
||||
7. Restart the GitHub Copilot app so the new tools become available to the
|
||||
agent.
|
||||
|
||||
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.
|
||||
Use **Sandbox** on a connected server to inspect it in the Connector Namespace
|
||||
playground. Use **Change namespace** to switch subscriptions or namespaces.
|
||||
|
||||
## How it works
|
||||
## Authentication and privacy
|
||||
|
||||
- `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.
|
||||
Azure sign-in and connector sign-in are separate:
|
||||
|
||||
## Privacy & security
|
||||
- **Azure sign-in** lets the canvas discover and manage Connector Namespace
|
||||
resources. Access and refresh tokens are stored in the operating system's
|
||||
encrypted credential store. To select that encrypted cache entry after an app
|
||||
restart, the extension separately saves a non-secret authentication record
|
||||
containing the authority, client ID, account ID, tenant ID, and username under
|
||||
`~/.copilot/extensions/connector-namespaces/artifacts/azure-auth-record.json`,
|
||||
with user-only permissions where supported. Raw tokens are never written to
|
||||
extension files.
|
||||
- **Connector sign-in** grants an individual MCP server access to its backing
|
||||
service. The resulting connection is managed by Connector Namespace.
|
||||
|
||||
- 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.
|
||||
The canvas serves its interface from loopback only (`127.0.0.1`). Azure
|
||||
management requests are restricted to `https://management.azure.com/`.
|
||||
The gateway API key that lets Copilot reach a connected server is stored in the
|
||||
user-scoped GitHub Copilot MCP configuration and sent only to that server's
|
||||
configured HTTPS endpoint.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -1,62 +1,17 @@
|
||||
// ARM API client — fetches real connector data with Azure CLI credentials.
|
||||
// ARM API client — fetches real connector data with interactive Azure 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";
|
||||
import { platform } from "node:os";
|
||||
import { basename, isAbsolute, join, resolve, sep } from "node:path";
|
||||
import { getToken } from "./auth.mjs";
|
||||
|
||||
export { getToken };
|
||||
|
||||
const API_VERSION = "2026-05-01-preview";
|
||||
const RG_API_VERSION = "2021-04-01";
|
||||
const MSI_API_VERSION = "2023-01-31";
|
||||
const SUBS_API_VERSION = "2020-01-01";
|
||||
|
||||
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);
|
||||
@@ -92,29 +47,6 @@ async function trustedExecutablePath(path, expectedName, workspaceRoot = process
|
||||
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)]
|
||||
@@ -126,41 +58,6 @@ export async function resolveSystemExecutable(name, workspaceRoot = process.cwd(
|
||||
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.
|
||||
*/
|
||||
@@ -170,9 +67,13 @@ export async function getToken() {
|
||||
let s_subsCache = null; // { subs, expiresAt }
|
||||
const SUBS_TTL_MS = 30 * 60 * 1000;
|
||||
|
||||
export async function listSubscriptions() {
|
||||
export function invalidateSubscriptionsCache() {
|
||||
s_subsCache = null;
|
||||
}
|
||||
|
||||
export async function listSubscriptions({ forceRefresh = false } = {}) {
|
||||
const now = Date.now();
|
||||
if (s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs;
|
||||
if (!forceRefresh && s_subsCache && s_subsCache.expiresAt > now) return s_subsCache.subs;
|
||||
const token = await getToken();
|
||||
const url = `https://management.azure.com/subscriptions?api-version=${SUBS_API_VERSION}`;
|
||||
const raw = await paginateAll(url, token);
|
||||
|
||||
@@ -0,0 +1,292 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { promises as fs } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
deserializeAuthenticationRecord,
|
||||
InteractiveBrowserCredential,
|
||||
serializeAuthenticationRecord,
|
||||
useIdentityPlugin,
|
||||
} from "@azure/identity";
|
||||
import { cachePersistencePlugin } from "@azure/identity-cache-persistence";
|
||||
|
||||
export const ARM_SCOPE = "https://management.azure.com/.default";
|
||||
export const TOKEN_CACHE_NAME = "github-copilot-connector-namespaces";
|
||||
|
||||
const TOKEN_EXPIRY_SKEW_MS = 5 * 60 * 1000;
|
||||
const SIGN_IN_SESSION_TTL_MS = 10 * 60 * 1000;
|
||||
const AUTH_STORAGE_DIR = join(
|
||||
process.env.COPILOT_HOME || join(homedir(), ".copilot"),
|
||||
"extensions",
|
||||
"connector-namespaces",
|
||||
"artifacts",
|
||||
);
|
||||
const AUTH_RECORD_FILE = join(AUTH_STORAGE_DIR, "azure-auth-record.json");
|
||||
const LEGACY_AUTH_CACHE = join(AUTH_STORAGE_DIR, "auth-cache.json");
|
||||
|
||||
let legacyAuthCacheRemoved = false;
|
||||
|
||||
useIdentityPlugin(cachePersistencePlugin);
|
||||
|
||||
export async function loadAuthenticationRecord({
|
||||
readFile = fs.readFile,
|
||||
deserialize = deserializeAuthenticationRecord,
|
||||
authRecordFile = AUTH_RECORD_FILE,
|
||||
} = {}) {
|
||||
let serialized;
|
||||
try {
|
||||
serialized = await readFile(authRecordFile, "utf-8");
|
||||
} catch (error) {
|
||||
if (error?.code === "ENOENT") return undefined;
|
||||
throw error;
|
||||
}
|
||||
try {
|
||||
return deserialize(serialized);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAuthenticationRecord(record) {
|
||||
await fs.mkdir(AUTH_STORAGE_DIR, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(AUTH_STORAGE_DIR, 0o700);
|
||||
await fs.writeFile(
|
||||
AUTH_RECORD_FILE,
|
||||
serializeAuthenticationRecord(record),
|
||||
{ encoding: "utf-8", mode: 0o600 },
|
||||
);
|
||||
await fs.chmod(AUTH_RECORD_FILE, 0o600);
|
||||
}
|
||||
|
||||
async function removeLegacyAuthCache() {
|
||||
if (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}`);
|
||||
}
|
||||
}
|
||||
legacyAuthCacheRemoved = true;
|
||||
}
|
||||
|
||||
export class ConnectorAuthenticationRequiredError extends Error {
|
||||
constructor(message = "Sign in to Azure to continue.", options) {
|
||||
super(message, options);
|
||||
this.name = "ConnectorAuthenticationRequiredError";
|
||||
this.code = "authentication_required";
|
||||
}
|
||||
}
|
||||
|
||||
export function isAuthenticationRequiredError(error) {
|
||||
return error instanceof ConnectorAuthenticationRequiredError
|
||||
|| error?.code === "authentication_required"
|
||||
|| error?.name === "AuthenticationRequiredError";
|
||||
}
|
||||
|
||||
function credentialFactory(options) {
|
||||
return new InteractiveBrowserCredential(options);
|
||||
}
|
||||
|
||||
function hasUsableToken(accessToken, now) {
|
||||
return !!accessToken?.token
|
||||
&& Number.isFinite(accessToken.expiresOnTimestamp)
|
||||
&& accessToken.expiresOnTimestamp - TOKEN_EXPIRY_SKEW_MS > now;
|
||||
}
|
||||
|
||||
function errorDetail(error) {
|
||||
return String(error?.message || error || "Azure sign-in failed.").slice(0, 400);
|
||||
}
|
||||
|
||||
export class InteractiveAuthBroker {
|
||||
constructor({
|
||||
createCredential = credentialFactory,
|
||||
createSessionId = randomUUID,
|
||||
cleanupLegacyCredentials = async () => {},
|
||||
loadAuthRecord = async () => undefined,
|
||||
saveAuthRecord = async () => {},
|
||||
now = Date.now,
|
||||
scope = ARM_SCOPE,
|
||||
tokenCacheName = TOKEN_CACHE_NAME,
|
||||
} = {}) {
|
||||
this.createCredential = createCredential;
|
||||
this.createSessionId = createSessionId;
|
||||
this.cleanupLegacyCredentials = cleanupLegacyCredentials;
|
||||
this.loadAuthRecord = loadAuthRecord;
|
||||
this.saveAuthRecord = saveAuthRecord;
|
||||
this.now = now;
|
||||
this.scope = scope;
|
||||
this.tokenCacheName = tokenCacheName;
|
||||
this.credential = null;
|
||||
this.accessToken = null;
|
||||
this.cleanupInFlight = null;
|
||||
this.tokenInFlight = null;
|
||||
this.sessions = new Map();
|
||||
}
|
||||
|
||||
createInteractiveCredential(authenticationRecord) {
|
||||
return this.createCredential({
|
||||
redirectUri: "http://localhost",
|
||||
disableAutomaticAuthentication: true,
|
||||
tokenCachePersistenceOptions: {
|
||||
enabled: true,
|
||||
name: this.tokenCacheName,
|
||||
},
|
||||
...(authenticationRecord ? { authenticationRecord } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
ensureLegacyCredentialsRemoved() {
|
||||
if (!this.cleanupInFlight) {
|
||||
const cleanup = Promise.resolve()
|
||||
.then(() => this.cleanupLegacyCredentials())
|
||||
.catch((error) => {
|
||||
if (this.cleanupInFlight === cleanup) this.cleanupInFlight = null;
|
||||
throw error;
|
||||
});
|
||||
this.cleanupInFlight = cleanup;
|
||||
}
|
||||
return this.cleanupInFlight;
|
||||
}
|
||||
|
||||
pruneSessions() {
|
||||
const cutoff = this.now() - SIGN_IN_SESSION_TTL_MS;
|
||||
for (const [sessionId, session] of this.sessions) {
|
||||
if (session.createdAt >= cutoff) continue;
|
||||
session.status = "cancelled";
|
||||
session.abortController.abort();
|
||||
this.sessions.delete(sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
startSignIn() {
|
||||
this.pruneSessions();
|
||||
const sessionId = this.createSessionId();
|
||||
const abortController = new AbortController();
|
||||
let credential;
|
||||
try {
|
||||
credential = this.createInteractiveCredential();
|
||||
} catch (error) {
|
||||
return { ok: false, reason: "identity_unavailable", error: errorDetail(error) };
|
||||
}
|
||||
|
||||
const session = {
|
||||
abortController,
|
||||
createdAt: this.now(),
|
||||
error: "",
|
||||
status: "pending",
|
||||
};
|
||||
this.sessions.set(sessionId, session);
|
||||
|
||||
session.promise = Promise.resolve()
|
||||
.then(async () => {
|
||||
await this.ensureLegacyCredentialsRemoved();
|
||||
const authenticationRecord = await credential.authenticate(
|
||||
this.scope,
|
||||
{ abortSignal: abortController.signal },
|
||||
);
|
||||
if (!authenticationRecord) {
|
||||
throw new Error("Azure identity did not return an authentication record.");
|
||||
}
|
||||
await this.saveAuthRecord(authenticationRecord);
|
||||
const accessToken = await credential.getToken(this.scope, { abortSignal: abortController.signal });
|
||||
if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) {
|
||||
throw new Error("Azure identity returned an incomplete ARM access token.");
|
||||
}
|
||||
if (session.status !== "pending" || this.sessions.get(sessionId) !== session) return;
|
||||
this.credential = credential;
|
||||
this.accessToken = accessToken;
|
||||
session.status = "done";
|
||||
})
|
||||
.catch((error) => {
|
||||
if (session.status !== "pending") return;
|
||||
session.status = abortController.signal.aborted ? "cancelled" : "error";
|
||||
if (session.status === "error") session.error = errorDetail(error);
|
||||
});
|
||||
|
||||
return { ok: true, sessionId, mode: "interactive" };
|
||||
}
|
||||
|
||||
getSignInStatus(sessionId) {
|
||||
this.pruneSessions();
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session) return { ok: false, status: "unknown" };
|
||||
if (session.status === "pending") return { ok: true, status: "pending", mode: "interactive" };
|
||||
|
||||
if (session.status === "done") return { ok: true, status: "done" };
|
||||
if (session.status === "cancelled") return { ok: true, status: "cancelled" };
|
||||
return { ok: false, status: "error", error: session.error || "Azure sign-in failed." };
|
||||
}
|
||||
|
||||
cancelSignIn(sessionId) {
|
||||
const session = this.sessions.get(sessionId);
|
||||
if (!session || session.status !== "pending") return { ok: true };
|
||||
session.status = "cancelled";
|
||||
session.abortController.abort();
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async getToken() {
|
||||
await this.ensureLegacyCredentialsRemoved();
|
||||
if (hasUsableToken(this.accessToken, this.now())) return this.accessToken.token;
|
||||
if (this.tokenInFlight) return this.tokenInFlight;
|
||||
|
||||
const request = (async () => {
|
||||
let credential = this.credential;
|
||||
if (!credential) {
|
||||
try {
|
||||
const authenticationRecord = await this.loadAuthRecord();
|
||||
if (hasUsableToken(this.accessToken, this.now())) return this.accessToken.token;
|
||||
credential = this.credential;
|
||||
if (!credential) {
|
||||
credential = this.createInteractiveCredential(authenticationRecord);
|
||||
this.credential = credential;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isAuthenticationRequiredError(error)) {
|
||||
throw new ConnectorAuthenticationRequiredError(
|
||||
"Sign in to Azure to continue.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
try {
|
||||
const accessToken = await credential.getToken(this.scope);
|
||||
if (!accessToken?.token || !Number.isFinite(accessToken.expiresOnTimestamp)) {
|
||||
throw new Error("Azure identity returned an incomplete ARM access token.");
|
||||
}
|
||||
this.accessToken = accessToken;
|
||||
return accessToken.token;
|
||||
} catch (error) {
|
||||
if (!isAuthenticationRequiredError(error)) throw error;
|
||||
if (this.credential === credential) {
|
||||
this.credential = null;
|
||||
this.accessToken = null;
|
||||
}
|
||||
throw new ConnectorAuthenticationRequiredError(
|
||||
"Sign in to Azure to continue.",
|
||||
{ cause: error },
|
||||
);
|
||||
}
|
||||
})();
|
||||
this.tokenInFlight = request;
|
||||
try {
|
||||
return await request;
|
||||
} finally {
|
||||
if (this.tokenInFlight === request) this.tokenInFlight = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const interactiveAuth = new InteractiveAuthBroker({
|
||||
cleanupLegacyCredentials: removeLegacyAuthCache,
|
||||
loadAuthRecord: loadAuthenticationRecord,
|
||||
saveAuthRecord: saveAuthenticationRecord,
|
||||
});
|
||||
|
||||
export const startSignIn = () => interactiveAuth.startSignIn();
|
||||
export const getSignInStatus = (sessionId) => interactiveAuth.getSignInStatus(sessionId);
|
||||
export const cancelSignIn = (sessionId) => interactiveAuth.cancelSignIn(sessionId);
|
||||
export const getToken = () => interactiveAuth.getToken();
|
||||
@@ -0,0 +1,339 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
ConnectorAuthenticationRequiredError,
|
||||
InteractiveAuthBroker,
|
||||
loadAuthenticationRecord,
|
||||
TOKEN_CACHE_NAME,
|
||||
} from "./auth.mjs";
|
||||
|
||||
function accessToken(token = "token", expiresOnTimestamp = 2_000_000_000_000) {
|
||||
return { token, expiresOnTimestamp };
|
||||
}
|
||||
|
||||
function authenticationRecord(username = "user@example.com") {
|
||||
return {
|
||||
authority: "login.microsoftonline.com",
|
||||
homeAccountId: "home-account",
|
||||
clientId: "client-id",
|
||||
tenantId: "tenant-id",
|
||||
username,
|
||||
};
|
||||
}
|
||||
|
||||
test("ARM token requests require an explicit browser sign-in", async () => {
|
||||
let credentialOptions;
|
||||
const broker = new InteractiveAuthBroker({
|
||||
createCredential(options) {
|
||||
credentialOptions = options;
|
||||
return {
|
||||
async getToken() {
|
||||
const error = new Error("No cached account found.");
|
||||
error.name = "AuthenticationRequiredError";
|
||||
throw error;
|
||||
},
|
||||
};
|
||||
},
|
||||
loadAuthRecord: async () => undefined,
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
broker.getToken(),
|
||||
(error) => error instanceof ConnectorAuthenticationRequiredError
|
||||
&& error.code === "authentication_required",
|
||||
);
|
||||
assert.deepEqual(credentialOptions.tokenCachePersistenceOptions, {
|
||||
enabled: true,
|
||||
name: TOKEN_CACHE_NAME,
|
||||
});
|
||||
});
|
||||
|
||||
test("a malformed authentication record falls back to browser sign-in", async () => {
|
||||
assert.equal(
|
||||
await loadAuthenticationRecord({
|
||||
readFile: async () => "{malformed-json",
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("authentication record read failures remain operational errors", async () => {
|
||||
const readError = Object.assign(new Error("authentication record is unreadable"), { code: "EACCES" });
|
||||
await assert.rejects(
|
||||
loadAuthenticationRecord({
|
||||
readFile: async () => { throw readError; },
|
||||
}),
|
||||
(error) => error === readError,
|
||||
);
|
||||
});
|
||||
|
||||
test("interactive sign-in reports pending then done and caches the ARM token", async () => {
|
||||
let credentialOptions;
|
||||
let authenticateOptions;
|
||||
const credential = {
|
||||
async authenticate(scope, options) {
|
||||
assert.equal(scope, "https://management.azure.com/.default");
|
||||
authenticateOptions = options;
|
||||
return authenticationRecord();
|
||||
},
|
||||
async getToken(scope, options) {
|
||||
assert.equal(scope, "https://management.azure.com/.default");
|
||||
assert.equal(options.abortSignal, authenticateOptions.abortSignal);
|
||||
return accessToken();
|
||||
},
|
||||
};
|
||||
const broker = new InteractiveAuthBroker({
|
||||
createCredential(options) {
|
||||
credentialOptions = options;
|
||||
return credential;
|
||||
},
|
||||
createSessionId: () => "signin-session",
|
||||
saveAuthRecord: async (record) => assert.deepEqual(record, authenticationRecord()),
|
||||
now: () => 1_000,
|
||||
});
|
||||
|
||||
const started = broker.startSignIn();
|
||||
assert.deepEqual(started, {
|
||||
ok: true,
|
||||
sessionId: "signin-session",
|
||||
mode: "interactive",
|
||||
});
|
||||
assert.deepEqual(broker.getSignInStatus(started.sessionId), {
|
||||
ok: true,
|
||||
status: "pending",
|
||||
mode: "interactive",
|
||||
});
|
||||
|
||||
await broker.sessions.get(started.sessionId).promise;
|
||||
|
||||
assert.deepEqual(credentialOptions, {
|
||||
redirectUri: "http://localhost",
|
||||
disableAutomaticAuthentication: true,
|
||||
tokenCachePersistenceOptions: {
|
||||
enabled: true,
|
||||
name: TOKEN_CACHE_NAME,
|
||||
},
|
||||
});
|
||||
assert.equal(authenticateOptions.abortSignal.aborted, false);
|
||||
assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" });
|
||||
assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" });
|
||||
assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true });
|
||||
assert.equal(authenticateOptions.abortSignal.aborted, false);
|
||||
assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "done" });
|
||||
assert.equal(await broker.getToken(), "token");
|
||||
});
|
||||
|
||||
test("a new broker restores the persisted credential without reopening the browser", async () => {
|
||||
const cache = { record: null, token: null };
|
||||
let authenticateCalls = 0;
|
||||
const createCredential = (options) => {
|
||||
assert.deepEqual(options.tokenCachePersistenceOptions, {
|
||||
enabled: true,
|
||||
name: TOKEN_CACHE_NAME,
|
||||
});
|
||||
return {
|
||||
async authenticate() {
|
||||
authenticateCalls++;
|
||||
cache.token = accessToken("persisted-token");
|
||||
return authenticationRecord();
|
||||
},
|
||||
async getToken() {
|
||||
if (cache.token && options.authenticationRecord) return cache.token;
|
||||
const error = new Error("No cached account found.");
|
||||
error.name = "AuthenticationRequiredError";
|
||||
throw error;
|
||||
},
|
||||
};
|
||||
};
|
||||
const signedInBroker = new InteractiveAuthBroker({
|
||||
createCredential,
|
||||
createSessionId: () => "persist-session",
|
||||
saveAuthRecord: async (record) => { cache.record = record; },
|
||||
now: () => 1_000,
|
||||
});
|
||||
const started = signedInBroker.startSignIn();
|
||||
await signedInBroker.sessions.get(started.sessionId).promise;
|
||||
assert.equal(authenticateCalls, 1);
|
||||
|
||||
const restartedBroker = new InteractiveAuthBroker({
|
||||
createCredential,
|
||||
loadAuthRecord: async () => cache.record,
|
||||
now: () => 1_000,
|
||||
});
|
||||
assert.equal(await restartedBroker.getToken(), "persisted-token");
|
||||
assert.equal(authenticateCalls, 1);
|
||||
});
|
||||
|
||||
test("concurrent first-time token requests share credential initialization and acquisition", async () => {
|
||||
let releaseAuthenticationRecord;
|
||||
const authenticationRecordReady = new Promise((resolve) => {
|
||||
releaseAuthenticationRecord = resolve;
|
||||
});
|
||||
let loadAuthRecordCalls = 0;
|
||||
let createCredentialCalls = 0;
|
||||
let tokenCalls = 0;
|
||||
const broker = new InteractiveAuthBroker({
|
||||
async loadAuthRecord() {
|
||||
loadAuthRecordCalls++;
|
||||
await authenticationRecordReady;
|
||||
return authenticationRecord();
|
||||
},
|
||||
createCredential: () => {
|
||||
createCredentialCalls++;
|
||||
return {
|
||||
async getToken() {
|
||||
tokenCalls++;
|
||||
return accessToken("shared-token");
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const firstRequest = broker.getToken();
|
||||
const secondRequest = broker.getToken();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
const loadsBeforeRelease = loadAuthRecordCalls;
|
||||
releaseAuthenticationRecord();
|
||||
|
||||
assert.deepEqual(
|
||||
await Promise.all([firstRequest, secondRequest]),
|
||||
["shared-token", "shared-token"],
|
||||
);
|
||||
assert.equal(loadsBeforeRelease, 1);
|
||||
assert.equal(loadAuthRecordCalls, 1);
|
||||
assert.equal(createCredentialCalls, 1);
|
||||
assert.equal(tokenCalls, 1);
|
||||
});
|
||||
|
||||
test("cancelling sign-in aborts the credential request", async () => {
|
||||
let abortSignal;
|
||||
const credential = {
|
||||
authenticate(_scope, options) {
|
||||
abortSignal = options.abortSignal;
|
||||
return new Promise((resolve, reject) => {
|
||||
options.abortSignal.addEventListener("abort", () => reject(new Error("aborted")), { once: true });
|
||||
});
|
||||
},
|
||||
async getToken() {
|
||||
const error = new Error("No cached account found.");
|
||||
error.name = "AuthenticationRequiredError";
|
||||
throw error;
|
||||
},
|
||||
};
|
||||
const broker = new InteractiveAuthBroker({
|
||||
createCredential: () => credential,
|
||||
createSessionId: () => "cancel-session",
|
||||
loadAuthRecord: async () => undefined,
|
||||
now: () => 1_000,
|
||||
});
|
||||
|
||||
const started = broker.startSignIn();
|
||||
const pending = broker.sessions.get(started.sessionId).promise;
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(abortSignal.aborted, false);
|
||||
|
||||
assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true });
|
||||
await pending;
|
||||
|
||||
assert.equal(abortSignal.aborted, true);
|
||||
assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "cancelled" });
|
||||
assert.deepEqual(broker.getSignInStatus(started.sessionId), { ok: true, status: "cancelled" });
|
||||
await assert.rejects(broker.getToken(), ConnectorAuthenticationRequiredError);
|
||||
});
|
||||
|
||||
test("sign-in failures are surfaced through the status endpoint contract", async () => {
|
||||
const broker = new InteractiveAuthBroker({
|
||||
createCredential: () => ({
|
||||
async authenticate() {
|
||||
throw new Error("browser launch failed");
|
||||
},
|
||||
async getToken() {
|
||||
throw new Error("unreachable");
|
||||
},
|
||||
}),
|
||||
createSessionId: () => "failed-session",
|
||||
now: () => 1_000,
|
||||
});
|
||||
|
||||
const started = broker.startSignIn();
|
||||
await broker.sessions.get(started.sessionId).promise;
|
||||
|
||||
assert.deepEqual(broker.getSignInStatus(started.sessionId), {
|
||||
ok: false,
|
||||
status: "error",
|
||||
error: "browser launch failed",
|
||||
});
|
||||
assert.deepEqual(broker.getSignInStatus(started.sessionId), {
|
||||
ok: false,
|
||||
status: "error",
|
||||
error: "browser launch failed",
|
||||
});
|
||||
assert.deepEqual(broker.cancelSignIn(started.sessionId), { ok: true });
|
||||
assert.deepEqual(broker.getSignInStatus(started.sessionId), {
|
||||
ok: false,
|
||||
status: "error",
|
||||
error: "browser launch failed",
|
||||
});
|
||||
});
|
||||
|
||||
test("legacy credential cleanup retries after a transient failure", async () => {
|
||||
let cleanupCalls = 0;
|
||||
const broker = new InteractiveAuthBroker({
|
||||
cleanupLegacyCredentials: async () => {
|
||||
cleanupCalls++;
|
||||
if (cleanupCalls === 1) throw new Error("legacy cache is locked");
|
||||
},
|
||||
createCredential: () => ({
|
||||
async getToken() {
|
||||
return accessToken();
|
||||
},
|
||||
}),
|
||||
loadAuthRecord: async () => authenticationRecord(),
|
||||
});
|
||||
|
||||
await assert.rejects(broker.getToken(), /legacy cache is locked/);
|
||||
assert.equal(await broker.getToken(), "token");
|
||||
assert.equal(cleanupCalls, 2);
|
||||
});
|
||||
|
||||
test("token acquisition preserves operational errors and retries the credential", async () => {
|
||||
const outage = new Error("Azure Identity network request timed out");
|
||||
let createCredentialCalls = 0;
|
||||
let tokenCalls = 0;
|
||||
const broker = new InteractiveAuthBroker({
|
||||
createCredential: () => {
|
||||
createCredentialCalls++;
|
||||
return {
|
||||
async getToken() {
|
||||
tokenCalls++;
|
||||
if (tokenCalls === 1) throw outage;
|
||||
return accessToken();
|
||||
},
|
||||
};
|
||||
},
|
||||
loadAuthRecord: async () => authenticationRecord(),
|
||||
});
|
||||
|
||||
await assert.rejects(broker.getToken(), (error) => error === outage);
|
||||
assert.equal(await broker.getToken(), "token");
|
||||
assert.equal(createCredentialCalls, 1);
|
||||
assert.equal(tokenCalls, 2);
|
||||
});
|
||||
|
||||
test("incomplete tokens remain operational errors instead of prompting sign-in", async () => {
|
||||
const broker = new InteractiveAuthBroker({
|
||||
createCredential: () => ({
|
||||
async getToken() {
|
||||
return { token: "incomplete" };
|
||||
},
|
||||
}),
|
||||
loadAuthRecord: async () => authenticationRecord(),
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
broker.getToken(),
|
||||
(error) => !(error instanceof ConnectorAuthenticationRequiredError)
|
||||
&& error.message === "Azure identity returned an incomplete ARM access token.",
|
||||
);
|
||||
});
|
||||
@@ -44,7 +44,7 @@ const session = await joinSession({
|
||||
createCanvas({
|
||||
id: "connector-namespaces",
|
||||
displayName: "MCP Connectors",
|
||||
description: "Browse, connect, and open MCP connectors in the Azure Connector Namespace Sandbox.",
|
||||
description: "Discover, connect, and manage hosted MCP servers from Azure Connector Namespace.",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
|
||||
+12
-12
@@ -12,26 +12,18 @@
|
||||
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 { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
// Isolate COPILOT_HOME before importing install.mjs because its paths are bound at
|
||||
// module-eval time. Put a fake Azure CLI on PATH so getToken() stays offline, and
|
||||
// seed a profile config so the local entry reads as inCli.
|
||||
// module-eval time. Seed the interactive broker with an in-memory token so ARM
|
||||
// calls stay offline, and seed a profile config so the local entry reads as inCli.
|
||||
const TMP = mkdtempSync(join(tmpdir(), "cn-reauth-"));
|
||||
process.env.COPILOT_HOME = TMP;
|
||||
process.env.USERPROFILE = TMP; // homedir() on Windows
|
||||
process.env.HOME = TMP; // homedir() on posix
|
||||
|
||||
const 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" }));
|
||||
@@ -41,6 +33,14 @@ writeFileSync(
|
||||
JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }),
|
||||
);
|
||||
|
||||
const { interactiveAuth } = await import("./auth.mjs");
|
||||
interactiveAuth.credential = {
|
||||
async getToken() {
|
||||
return { token: "fake-token", expiresOnTimestamp: Date.now() + 60 * 60 * 1000 };
|
||||
},
|
||||
};
|
||||
interactiveAuth.accessToken = { token: "fake-token", expiresOnTimestamp: Date.now() + 60 * 60 * 1000 };
|
||||
|
||||
// Dynamic import AFTER the env is set. A static top-level import would be hoisted
|
||||
// and evaluate install.mjs (binding the paths to the real home) before the env
|
||||
// assignments run.
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "connector-namespaces",
|
||||
"version": "1.1.0",
|
||||
"version": "1.2.0",
|
||||
"type": "module",
|
||||
"main": "extension.mjs",
|
||||
"description": "Browse, connect, and open MCP connectors from your Azure Connector Namespace in Sandbox.",
|
||||
"description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.",
|
||||
"keywords": [
|
||||
"azure",
|
||||
"connector-namespace",
|
||||
@@ -14,6 +14,8 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@azure/identity": "4.13.1",
|
||||
"@azure/identity-cache-persistence": "1.3.0",
|
||||
"@github/copilot-sdk": "1.0.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ 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).
|
||||
"Restart the GitHub Copilot app…" banner).
|
||||
|
||||
This exists because those two bugs have each shipped multiple times:
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ export function baseStyles() {
|
||||
}
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
[hidden] { display: none !important; }
|
||||
body {
|
||||
font-family: "Segoe UI Variable", "Segoe UI", -apple-system, system-ui, sans-serif;
|
||||
margin: 0;
|
||||
@@ -303,7 +304,7 @@ button:focus-visible, a:focus-visible, [tabindex]:focus-visible { outline: 2px s
|
||||
// Setup / Namespace Picker
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function renderSetupHtml(subscriptions, notice = "", capabilityToken = "") {
|
||||
export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken = "") {
|
||||
const subOptions = subscriptions.map((s) =>
|
||||
`<option value="${s.id}">${esc(s.name)} (${s.id.slice(0, 8)}\u2026)</option>`
|
||||
).join("");
|
||||
@@ -336,6 +337,19 @@ export function renderSetupHtml(subscriptions, notice = "", capabilityToken = ""
|
||||
}
|
||||
.create-link:hover { background: var(--accent); color: #fff; }
|
||||
.create-link .plus { font-size: 1.05rem; line-height: 1; font-weight: 700; }
|
||||
.create-link:disabled { opacity: .55; cursor: not-allowed; background: transparent; color: var(--fg-muted); border-color: var(--border-strong); }
|
||||
.signin-panel { margin: .25rem 0 1rem; max-width: 540px; }
|
||||
.signin-message { color: var(--fg-muted); font-size: .82rem; line-height: 1.5; margin: 0 0 .7rem; }
|
||||
.signin-actions { display: flex; align-items: center; gap: .35rem; }
|
||||
.signin-primary { min-width: 0; padding: .35rem .75rem; font-size: .82rem; }
|
||||
.signin-cancel {
|
||||
appearance: none; border: 0; border-radius: 4px; background: transparent;
|
||||
color: var(--fg-muted); padding: .3rem .45rem; font: inherit; font-size: .78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.signin-cancel:hover { color: var(--accent); background: var(--bg-hover); }
|
||||
.signin-actions button:disabled { opacity: .55; cursor: wait; }
|
||||
.signin-panel[data-state="error"] .signin-message { color: var(--danger); }
|
||||
.setup-notice {
|
||||
margin: 0 0 1rem; padding: .55rem .7rem; border-radius: 6px;
|
||||
background: var(--bg-pill); border: 1px solid var(--accent);
|
||||
@@ -347,19 +361,28 @@ export function renderSetupHtml(subscriptions, notice = "", capabilityToken = ""
|
||||
<div class="sub">Choose which connector namespace to browse. This choice is saved for future sessions.</div>
|
||||
</div>
|
||||
${notice ? `<div class="setup-notice">${esc(notice)}</div>` : ""}
|
||||
<div id="signin-panel" class="signin-panel" data-state="idle" aria-busy="false" hidden>
|
||||
<p id="signin-message" class="signin-message" aria-live="polite">Sign in to Azure to load your subscriptions and connector namespaces.</p>
|
||||
<div class="signin-actions">
|
||||
<button id="signin-btn" class="item-add primary signin-primary" type="button">Sign in to Azure</button>
|
||||
<button id="cancel-signin-btn" class="signin-cancel" type="button" hidden>Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="setup-content">
|
||||
<div class="create-row">
|
||||
<button id="create-ns-btn" class="create-link" type="button"><span class="plus">+</span><span>New connector namespace</span></button>
|
||||
<button id="create-ns-btn" class="create-link" type="button"${subscriptions.length ? "" : " disabled"}><span class="plus">+</span><span>New connector namespace</span></button>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label for="sub-select">Subscription</label>
|
||||
<select id="sub-select">
|
||||
<select id="sub-select"${subscriptions.length ? "" : " disabled"}>
|
||||
<option value="">-- Select subscription --</option>
|
||||
${subOptions}
|
||||
</select>
|
||||
</div>
|
||||
<input id="gw-filter" type="text" placeholder="Filter namespaces by name\u2026" autocomplete="off" spellcheck="false">
|
||||
<div id="gateway-list">
|
||||
<div class="empty">Select a subscription to see available connector namespaces.</div>
|
||||
<div class="empty">${subscriptions.length ? "Select a subscription to see available connector namespaces." : "Loading subscriptions\u2026"}</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
const connectorNamespaceToken = ${JSON.stringify(capabilityToken)};
|
||||
@@ -379,7 +402,15 @@ window.fetch = (input, init = {}) => {
|
||||
const subSelect = document.getElementById("sub-select");
|
||||
const gatewayList = document.getElementById("gateway-list");
|
||||
const gwFilter = document.getElementById("gw-filter");
|
||||
document.getElementById("create-ns-btn").addEventListener("click", () => {
|
||||
const setupContent = document.getElementById("setup-content");
|
||||
const createNamespaceButton = document.getElementById("create-ns-btn");
|
||||
const signinPanel = document.getElementById("signin-panel");
|
||||
const signinMessage = document.getElementById("signin-message");
|
||||
const signinButton = document.getElementById("signin-btn");
|
||||
const cancelSigninButton = document.getElementById("cancel-signin-btn");
|
||||
const signin = { sessionId: null, timer: null, starting: false };
|
||||
|
||||
createNamespaceButton.addEventListener("click", () => {
|
||||
window.location.href = "/create" + (subSelect.value ? "?subscriptionId=" + encodeURIComponent(subSelect.value) : "");
|
||||
});
|
||||
let allGateways = [];
|
||||
@@ -387,6 +418,158 @@ let hasMoreGateways = false;
|
||||
let loadedAll = false;
|
||||
let gatewayRequestSeq = 0;
|
||||
|
||||
function setSigninRequired(message, state = "idle") {
|
||||
signinPanel.hidden = false;
|
||||
signinPanel.dataset.state = state;
|
||||
signinPanel.setAttribute("aria-busy", "false");
|
||||
setupContent.hidden = true;
|
||||
signinMessage.textContent = message || "Sign in to Azure to load your subscriptions and connector namespaces.";
|
||||
signinButton.disabled = false;
|
||||
signinButton.hidden = false;
|
||||
cancelSigninButton.hidden = true;
|
||||
}
|
||||
|
||||
function setSetupReady() {
|
||||
signinPanel.hidden = true;
|
||||
signinPanel.dataset.state = "idle";
|
||||
signinPanel.setAttribute("aria-busy", "false");
|
||||
setupContent.hidden = false;
|
||||
subSelect.disabled = false;
|
||||
createNamespaceButton.disabled = false;
|
||||
}
|
||||
|
||||
function replaceSubscriptions(subscriptions) {
|
||||
subSelect.replaceChildren(new Option("-- Select subscription --", ""));
|
||||
for (const subscription of subscriptions) {
|
||||
const id = String(subscription.id || "");
|
||||
if (!id) continue;
|
||||
const name = String(subscription.name || id);
|
||||
subSelect.appendChild(new Option(name + " (" + id.slice(0, 8) + "\u2026)", id));
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSubscriptions(force) {
|
||||
subSelect.disabled = true;
|
||||
createNamespaceButton.disabled = true;
|
||||
gatewayList.innerHTML = '<div class="empty">Loading subscriptions\u2026</div>';
|
||||
try {
|
||||
const response = await fetch("/api/subscriptions" + (force ? "?refresh=true" : ""));
|
||||
const data = await response.json();
|
||||
if (data.reason === "not_signed_in") {
|
||||
replaceSubscriptions([]);
|
||||
setSigninRequired();
|
||||
return false;
|
||||
}
|
||||
if (!data.ok) throw new Error(data.error || "Could not load subscriptions.");
|
||||
replaceSubscriptions(Array.isArray(data.subscriptions) ? data.subscriptions : []);
|
||||
setSetupReady();
|
||||
gatewayList.innerHTML = '<div class="empty">Select a subscription to see available connector namespaces.</div>';
|
||||
return true;
|
||||
} catch (error) {
|
||||
setupContent.hidden = false;
|
||||
signinPanel.hidden = true;
|
||||
gatewayList.innerHTML = '<div class="empty" style="color:var(--danger);">Unable to load subscriptions: ' + escH(error.message) + '<br><button id="retry-subscriptions" class="change-btn" type="button" style="margin-top:.6rem;">Retry</button></div>';
|
||||
document.getElementById("retry-subscriptions").addEventListener("click", () => loadSubscriptions(true));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stopSigninPolling() {
|
||||
if (signin.timer) clearTimeout(signin.timer);
|
||||
signin.timer = null;
|
||||
signin.sessionId = null;
|
||||
signin.starting = false;
|
||||
signinPanel.setAttribute("aria-busy", "false");
|
||||
signinButton.disabled = false;
|
||||
cancelSigninButton.hidden = true;
|
||||
}
|
||||
|
||||
function scheduleSigninPoll() {
|
||||
if (signin.sessionId) signin.timer = setTimeout(pollSignin, 2500);
|
||||
}
|
||||
|
||||
async function startSignin() {
|
||||
if (signin.starting || signin.sessionId) return;
|
||||
signin.starting = true;
|
||||
signinPanel.dataset.state = "pending";
|
||||
signinPanel.setAttribute("aria-busy", "true");
|
||||
signinButton.disabled = true;
|
||||
signinMessage.textContent = "Opening your browser for Azure sign-in\u2026";
|
||||
try {
|
||||
const response = await fetch("/api/signin", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: "{}",
|
||||
});
|
||||
const data = await response.json();
|
||||
if (!data.ok || !data.sessionId) throw new Error(data.error || "Could not start Azure sign-in.");
|
||||
signin.sessionId = data.sessionId;
|
||||
signin.starting = false;
|
||||
signinPanel.dataset.state = "pending";
|
||||
signinButton.hidden = true;
|
||||
cancelSigninButton.hidden = false;
|
||||
signinMessage.textContent = "Complete sign-in in the browser window. This page will update automatically.";
|
||||
scheduleSigninPoll();
|
||||
} catch (error) {
|
||||
signin.starting = false;
|
||||
signinPanel.dataset.state = "error";
|
||||
signinPanel.setAttribute("aria-busy", "false");
|
||||
signinButton.disabled = false;
|
||||
signinMessage.textContent = "Unable to start sign-in: " + error.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollSignin() {
|
||||
const sessionId = signin.sessionId;
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
const response = await fetch("/api/signin/status?sessionId=" + encodeURIComponent(sessionId));
|
||||
const data = await response.json();
|
||||
if (sessionId !== signin.sessionId) return;
|
||||
if (data.status === "done") {
|
||||
stopSigninPolling();
|
||||
signinPanel.dataset.state = "pending";
|
||||
signinPanel.setAttribute("aria-busy", "true");
|
||||
signinMessage.textContent = "Signed in. Loading subscriptions\u2026";
|
||||
signinPanel.hidden = false;
|
||||
await loadSubscriptions(true);
|
||||
return;
|
||||
}
|
||||
if (data.status === "error" || data.status === "cancelled" || data.status === "unknown") {
|
||||
stopSigninPolling();
|
||||
signinButton.hidden = false;
|
||||
setSigninRequired(
|
||||
data.status === "cancelled" ? "Sign-in was cancelled." : data.error || "Azure sign-in failed. Please try again.",
|
||||
data.status === "cancelled" ? "idle" : "error",
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// A transient loopback request failure should not cancel the browser flow.
|
||||
}
|
||||
scheduleSigninPoll();
|
||||
}
|
||||
|
||||
async function cancelSignin() {
|
||||
const sessionId = signin.sessionId;
|
||||
stopSigninPolling();
|
||||
signinButton.hidden = false;
|
||||
setSigninRequired("Sign-in was cancelled.", "idle");
|
||||
if (!sessionId) return;
|
||||
try {
|
||||
await fetch("/api/signin/cancel", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId }),
|
||||
});
|
||||
} catch {
|
||||
// The local broker also expires abandoned sign-in sessions.
|
||||
}
|
||||
}
|
||||
|
||||
signinButton.addEventListener("click", startSignin);
|
||||
cancelSigninButton.addEventListener("click", cancelSignin);
|
||||
|
||||
subSelect.addEventListener("change", async () => {
|
||||
const requestSeq = ++gatewayRequestSeq;
|
||||
const subId = subSelect.value;
|
||||
@@ -507,6 +690,9 @@ async function selectGateway(subscriptionId, resourceGroup, gatewayName) {
|
||||
if (data.ok) { window.location.href = "/"; }
|
||||
else { gatewayList.innerHTML = '<div class="empty" style="color:var(--danger);">Failed to save.</div>'; }
|
||||
}
|
||||
|
||||
if (${subscriptions.length > 0}) setSetupReady();
|
||||
else loadSubscriptions(false);
|
||||
</script></body></html>`;
|
||||
}
|
||||
|
||||
@@ -592,11 +778,6 @@ export function renderCatalogHtml(instanceId, catalog, { filter, category, sourc
|
||||
.restart-banner .rb-dismiss { flex:none; appearance:none; border:0; background:transparent; color:var(--fg-muted); font:inherit; font-size:.78rem; cursor:pointer; padding:.1rem .35rem; border-radius:4px; }
|
||||
.restart-banner .rb-dismiss:hover { color:var(--accent); background:var(--bg-hover); }
|
||||
.is-hidden { display:none !important; }
|
||||
/* The [hidden] attribute must always win. A class rule like .restart-banner{display:flex}
|
||||
has the same (0,1,0) specificity as the UA [hidden]{display:none} rule and, being an
|
||||
author rule, overrides it -- so setting el.hidden=true does nothing and dismiss silently
|
||||
breaks. This reset restores the attribute's authority for every element. */
|
||||
[hidden] { display:none !important; }
|
||||
|
||||
/* ---- split "remove" control + its popover menu + delete-confirm dialog ---- */
|
||||
/* main + caret read as one pill; the shared 1px border between them is the
|
||||
@@ -688,7 +869,7 @@ export function renderCatalogHtml(instanceId, catalog, { filter, category, sourc
|
||||
</div>
|
||||
<div id="restart-banner" class="restart-banner" role="status" hidden>
|
||||
<svg class="rb-ico" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" aria-hidden="true"><path d="M13.4 8a5.4 5.4 0 1 1-1.6-3.8"/><path d="M13.6 2.2v3h-3"/></svg>
|
||||
<div class="rb-body"><strong>Restart your Copilot session to use newly added tools.</strong><br>Connectors are saved to your MCP config now, but their tools only load when a session starts.</div>
|
||||
<div class="rb-body"><strong>Restart the GitHub Copilot app to use newly added tools.</strong><br>Connectors are saved to your MCP config now, but their tools only load when the app starts.</div>
|
||||
<button class="rb-dismiss" type="button" aria-label="Dismiss this message">Dismiss</button>
|
||||
</div>
|
||||
${sectionsHtml}
|
||||
@@ -866,9 +1047,9 @@ if (input.value) applyFilters();
|
||||
// for now because it writes a plaintext API key into a git-tracked .mcp.json.
|
||||
const installScope = "profile";
|
||||
|
||||
// --- Restart-required banner (tools load at session start) ---
|
||||
// --- Restart-required banner (tools load at app start) ---
|
||||
// Visibility is driven by the server's in-process pendingRestart flag via
|
||||
// /api/state, not local storage — a real session restart spawns a fresh
|
||||
// /api/state, not local storage — a full app restart spawns a fresh
|
||||
// extension process and clears it, so the banner can't go stale.
|
||||
const restartBanner = document.getElementById("restart-banner");
|
||||
// Once the user dismisses the banner, a late/racing hydrateState (its
|
||||
@@ -1068,7 +1249,7 @@ function openSignInModal(displayName, consentUrl) {
|
||||
cancellable = false;
|
||||
icon.innerHTML = '<div class="si-check">\u2713</div>';
|
||||
title.textContent = "Connected";
|
||||
sub.textContent = displayName + " is configured. Restart your Copilot session to load its tools.";
|
||||
sub.textContent = displayName + " is configured. Restart the GitHub Copilot app to load its tools.";
|
||||
meta.textContent = "";
|
||||
},
|
||||
close() { doClose(); },
|
||||
@@ -1116,7 +1297,7 @@ async function onConnect(btn) {
|
||||
pendingConn = null;
|
||||
}
|
||||
|
||||
await showConnectionSuccess(modal, item, 'Connected "' + displayName + '". Restart your session to use its tools.');
|
||||
await showConnectionSuccess(modal, item, 'Connected "' + displayName + '". Restart the GitHub Copilot app to use its tools.');
|
||||
} catch (err) {
|
||||
const recovery = await recoverConnectorFailure(
|
||||
err,
|
||||
@@ -1128,7 +1309,7 @@ async function onConnect(btn) {
|
||||
true
|
||||
);
|
||||
if (recovery.complete) {
|
||||
await showConnectionSuccess(modal, item, 'Connected "' + displayName + '". Restart your session to use its tools.');
|
||||
await showConnectionSuccess(modal, item, 'Connected "' + displayName + '". Restart the GitHub Copilot app to use its tools.');
|
||||
return;
|
||||
}
|
||||
if (modal) modal.close();
|
||||
@@ -1218,7 +1399,7 @@ async function onReauth(btn) {
|
||||
await showConnectionSuccess(
|
||||
modal,
|
||||
item,
|
||||
(isConnect ? 'Connected "' : 'Re-authenticated "') + displayName + '". Restart your session to use its tools.'
|
||||
(isConnect ? 'Connected "' : 'Re-authenticated "') + displayName + '". Restart the GitHub Copilot app to use its tools.'
|
||||
);
|
||||
} catch (err) {
|
||||
const recovery = await recoverConnectorFailure(
|
||||
@@ -1234,7 +1415,7 @@ async function onReauth(btn) {
|
||||
await showConnectionSuccess(
|
||||
modal,
|
||||
item,
|
||||
(isConnect ? 'Connected "' : 'Re-authenticated "') + displayName + '". Restart your session to use its tools.'
|
||||
(isConnect ? 'Connected "' : 'Re-authenticated "') + displayName + '". Restart the GitHub Copilot app to use its tools.'
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
// 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
|
||||
// 2. The "Restart the GitHub Copilot app" banner ignoring Dismiss. The real
|
||||
// root cause was CSS specificity: `.restart-banner{display:flex}` is an
|
||||
// author rule with the same (0,1,0) specificity as the UA
|
||||
// `[hidden]{display:none}` rule, so it overrode the hidden attribute and
|
||||
@@ -56,6 +56,33 @@ test("setup subscription label names its select", () => {
|
||||
assert.match(html, /<label for="sub-select">Subscription<\/label>/);
|
||||
});
|
||||
|
||||
test("setup prompts, polls, cancels, and reloads subscriptions after browser sign-in", () => {
|
||||
const html = renderSetupHtml([], "", "token");
|
||||
assert.match(html, /id="signin-btn"/);
|
||||
assert.match(html, /fetch\("\/api\/signin"/);
|
||||
assert.match(html, /\/api\/signin\/status\?sessionId=/);
|
||||
assert.match(html, /fetch\("\/api\/signin\/cancel"/);
|
||||
assert.match(html, /setTimeout\(pollSignin, 2500\)/);
|
||||
assert.match(html, /await loadSubscriptions\(true\)/);
|
||||
assert.match(html, /\/api\/subscriptions/);
|
||||
});
|
||||
|
||||
test("setup sign-in uses a compact borderless blank state", () => {
|
||||
const html = renderSetupHtml([], "", "token");
|
||||
assert.match(html, /id="signin-btn" class="item-add primary signin-primary"/);
|
||||
assert.match(html, /id="cancel-signin-btn" class="signin-cancel"/);
|
||||
assert.match(html, /Sign in to Azure to load your subscriptions and connector namespaces\./);
|
||||
assert.doesNotMatch(html, /signin-row|signin-icon|signin-title|>Azure account</);
|
||||
assert.doesNotMatch(html, /\.signin-panel\s*\{[^}]*(?:border|background|padding)\s*:/);
|
||||
});
|
||||
|
||||
test("setup browser script parses after rendering", () => {
|
||||
const html = renderSetupHtml([], "", "token");
|
||||
const script = html.match(/<script>([\s\S]*)<\/script>/)?.[1];
|
||||
assert.ok(script, "setup page must include its client script");
|
||||
assert.doesNotThrow(() => new Function(script));
|
||||
});
|
||||
|
||||
test("load-all and installed-state failures stay visible and fail closed", () => {
|
||||
const setup = renderSetupHtml([], "", "token");
|
||||
const catalog = catalogHtml();
|
||||
@@ -143,6 +170,12 @@ test("restart banner dismiss is sticky against a racing state refresh", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("restart guidance names the GitHub Copilot app rather than the session", () => {
|
||||
const html = catalogHtml();
|
||||
assert.match(html, /Restart the GitHub Copilot app to use newly added tools\./);
|
||||
assert.doesNotMatch(html, /Restart (?:your Copilot )?session/);
|
||||
});
|
||||
|
||||
test("a global [hidden] reset makes the hidden attribute authoritative", () => {
|
||||
// The actual dismiss bug: .restart-banner{display:flex} (an author rule)
|
||||
// ties the UA [hidden]{display:none} rule on specificity and wins, so the
|
||||
|
||||
+13
-47
@@ -1,10 +1,8 @@
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises";
|
||||
import { delimiter, join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { readFile } from "node:fs/promises";
|
||||
|
||||
import { armSegment, parseAzureCliToken, resolvePosixAzureCli, waitForProvisioning } from "./armClient.mjs";
|
||||
import { armSegment, waitForProvisioning } from "./armClient.mjs";
|
||||
|
||||
const here = new URL(".", import.meta.url);
|
||||
|
||||
@@ -51,50 +49,18 @@ test("namespace creation polls an empty 202 result until explicit success", asyn
|
||||
);
|
||||
});
|
||||
|
||||
test("Azure authentication is brokered by Azure CLI", async () => {
|
||||
const source = await readFile(new URL("armClient.mjs", here), "utf8");
|
||||
assert.match(source, /account get-access-token --resource/);
|
||||
assert.doesNotMatch(source, /04b07795-8ddb-461a-bbee-02f9e1bf7b46/);
|
||||
assert.doesNotMatch(source, /refreshToken/);
|
||||
assert.match(source, /await fs\.unlink\(LEGACY_AUTH_CACHE\)/);
|
||||
assert.match(source, /resolveWindowsAzureCli/);
|
||||
assert.match(source, /resolvePosixAzureCli/);
|
||||
assert.match(source, /fs\.realpath\(path\)/);
|
||||
assert.match(source, /windowsSystemExecutable\("cmd\.exe"\)/);
|
||||
assert.match(source, /\{ cwd: homedir\(\), encoding: "utf8"/);
|
||||
assert.deepEqual(
|
||||
parseAzureCliToken(JSON.stringify({ accessToken: "token", expires_on: 2_000_000_000 })),
|
||||
{ token: "token", expiresAt: 2_000_000_000_000 },
|
||||
);
|
||||
assert.throws(() => parseAzureCliToken("{}"), /incomplete ARM token/);
|
||||
});
|
||||
|
||||
test("POSIX Azure CLI resolution rejects workspace-controlled binaries", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "cn-az-path-"));
|
||||
const workspace = join(root, "workspace");
|
||||
const workspaceBin = join(workspace, "node_modules", ".bin");
|
||||
const trustedBin = join(root, "trusted-bin");
|
||||
await Promise.all([
|
||||
mkdir(workspaceBin, { recursive: true }),
|
||||
mkdir(trustedBin, { recursive: true }),
|
||||
test("Azure authentication uses an interactive browser and persistent encrypted cache", async () => {
|
||||
const [authSource, armSource] = await Promise.all([
|
||||
readFile(new URL("auth.mjs", here), "utf8"),
|
||||
readFile(new URL("armClient.mjs", here), "utf8"),
|
||||
]);
|
||||
await Promise.all([
|
||||
writeFile(join(workspaceBin, "az"), "workspace", { mode: 0o755 }),
|
||||
writeFile(join(trustedBin, "az"), "trusted", { mode: 0o755 }),
|
||||
]);
|
||||
try {
|
||||
const resolved = await resolvePosixAzureCli(
|
||||
[workspaceBin, trustedBin].join(delimiter),
|
||||
workspace,
|
||||
);
|
||||
assert.equal(resolved, await realpath(join(trustedBin, "az")));
|
||||
await assert.rejects(
|
||||
resolvePosixAzureCli(workspaceBin, workspace),
|
||||
/outside the current workspace/,
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
assert.match(authSource, /new InteractiveBrowserCredential\(options\)/);
|
||||
assert.match(authSource, /useIdentityPlugin\(cachePersistencePlugin\)/);
|
||||
assert.match(authSource, /disableAutomaticAuthentication: true/);
|
||||
assert.match(authSource, /tokenCachePersistenceOptions/);
|
||||
assert.match(authSource, /credential\.authenticate\(\s*this\.scope,\s*\{ abortSignal/);
|
||||
assert.match(authSource, /serializeAuthenticationRecord/);
|
||||
assert.doesNotMatch(armSource, /get-access-token|az login|resolvePosixAzureCli/);
|
||||
});
|
||||
|
||||
test("installer preserves capability tokens and persists direct HTTP entries", async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { fetchCatalog, invalidateCache } from "./catalog.mjs";
|
||||
import {
|
||||
listConnectorGateways,
|
||||
listSubscriptions,
|
||||
invalidateSubscriptionsCache,
|
||||
listResourceGroups,
|
||||
listUserAssignedIdentities,
|
||||
checkConnectorGatewayNameAvailable,
|
||||
@@ -16,6 +17,12 @@ import {
|
||||
createConnectorGateway,
|
||||
buildGatewayIdentity,
|
||||
} from "./armClient.mjs";
|
||||
import {
|
||||
cancelSignIn,
|
||||
getSignInStatus,
|
||||
isAuthenticationRequiredError,
|
||||
startSignIn,
|
||||
} from "./auth.mjs";
|
||||
import { installConnector, finishInstall, reauthConnector, finishReauth, openInBrowser, openMcpConfigFile, getInstalledState, uninstallConnector, removeLocalEntry, deleteConnection, prewarmMeta } from "./install.mjs";
|
||||
|
||||
const servers = new Map();
|
||||
@@ -83,8 +90,8 @@ export function runIdempotentOperation(operations, key, start, now = Date.now())
|
||||
|
||||
// Whether a connector was added during the life of THIS extension process.
|
||||
// MCP tools are only loaded by the CLI at session start, so an install done
|
||||
// after the process started isn't usable until the session restarts. A real
|
||||
// session restart spawns a fresh process and resets this to false. `acked`
|
||||
// after the process started isn't usable until the app restarts. A full app
|
||||
// restart spawns a fresh process and resets this to false. `acked`
|
||||
// lets the user dismiss the reminder for the rest of the process.
|
||||
let pendingRestart = false;
|
||||
let restartAcked = false;
|
||||
@@ -158,6 +165,44 @@ async function handleRequest(req, res, instanceId, serverEntry) {
|
||||
|
||||
// --- API routes ---
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/signin") {
|
||||
json(res, startSignIn());
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/signin/status") {
|
||||
const result = getSignInStatus(url.searchParams.get("sessionId") || "");
|
||||
if (result.status === "done") {
|
||||
invalidateSubscriptionsCache();
|
||||
gatewayCache.clear();
|
||||
invalidateCache();
|
||||
}
|
||||
json(res, result);
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/signin/cancel") {
|
||||
const body = await parseBody(req);
|
||||
json(res, cancelSignIn(body.sessionId || ""));
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "GET" && url.pathname === "/api/subscriptions") {
|
||||
try {
|
||||
const subscriptions = await listSubscriptions({
|
||||
forceRefresh: url.searchParams.get("refresh") === "true",
|
||||
});
|
||||
json(res, { ok: true, subscriptions });
|
||||
} catch (error) {
|
||||
if (isAuthenticationRequiredError(error)) {
|
||||
json(res, { ok: false, reason: "not_signed_in", subscriptions: [] });
|
||||
} else {
|
||||
json(res, { ok: false, error: error.message, subscriptions: [] });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (req.method === "POST" && url.pathname === "/api/add") {
|
||||
const body = await parseBody(req);
|
||||
const config = serverEntry.config;
|
||||
@@ -485,21 +530,19 @@ async function handleRequest(req, res, instanceId, serverEntry) {
|
||||
res.end(renderCreateNamespaceHtml(subs, preselected, serverEntry.token));
|
||||
} catch (err) {
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderErrorHtml(`Failed to load subscriptions. Run az login in a terminal, then reload this page.\n\n${err.message}`));
|
||||
if (isAuthenticationRequiredError(err)) {
|
||||
res.end(renderSetupHtml([], "Sign in to Azure before creating a connector namespace.", serverEntry.token));
|
||||
} else {
|
||||
res.end(renderErrorHtml(`Failed to load subscriptions.\n\n${err.message}`));
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Setup page (no gateway configured)
|
||||
if (!config || url.pathname === "/setup") {
|
||||
try {
|
||||
const subs = await listSubscriptions();
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderSetupHtml(subs, "", serverEntry.token));
|
||||
} catch (err) {
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderErrorHtml(`Failed to load subscriptions. Run az login in a terminal, then reload this page.\n\n${err.message}`));
|
||||
}
|
||||
res.end(renderSetupHtml([], "", serverEntry.token));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -516,20 +559,15 @@ async function handleRequest(req, res, instanceId, serverEntry) {
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderCatalogHtml(instanceId, catalog, { filter, category, source, config }, serverEntry.token));
|
||||
} catch (err) {
|
||||
// Trust-and-fallback: a saved namespace that can no longer be read
|
||||
// (deleted, access revoked, a transient outage) should drop the user
|
||||
// back to the picker, not a dead-end error page. Only if even the
|
||||
// picker can't load its subscriptions do we surface the raw error.
|
||||
try {
|
||||
const subs = await listSubscriptions();
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderSetupHtml(subs, `couldn't open namespace ${config.gatewayName} .. pick another to continue.`, serverEntry.token));
|
||||
} catch (subErr) {
|
||||
// Both the namespace catalog and the subscription list failed. Surface
|
||||
// the subscription error (the reason the picker itself can't render) and
|
||||
// keep the original namespace error for context.
|
||||
res.setHeader("Content-Type", "text/html; charset=utf-8");
|
||||
res.end(renderErrorHtml(`couldn't load subscriptions: ${subErr.message} .. opening namespace ${config.gatewayName} also failed: ${err.message}`));
|
||||
if (isAuthenticationRequiredError(err)) {
|
||||
res.end(renderSetupHtml([], "", serverEntry.token));
|
||||
} else {
|
||||
res.end(renderSetupHtml(
|
||||
[],
|
||||
`Couldn't load the saved namespace "${config.gatewayName}". Choose another namespace or try again.`,
|
||||
serverEntry.token,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,12 +10,19 @@
|
||||
// harness — through untouched. Importing server.mjs has no side effects at eval;
|
||||
// the HTTP server only starts when startServer() is called.
|
||||
|
||||
import { test } from "node:test";
|
||||
import { after, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Readable } from "node:stream";
|
||||
|
||||
import {
|
||||
const TEST_COPILOT_HOME = mkdtempSync(join(tmpdir(), "connector-server-test-"));
|
||||
process.env.COPILOT_HOME = TEST_COPILOT_HOME;
|
||||
after(() => rmSync(TEST_COPILOT_HOME, { recursive: true, force: true }));
|
||||
|
||||
const {
|
||||
getServerConfig,
|
||||
hasCapabilityToken,
|
||||
isCanonicalHost,
|
||||
@@ -26,8 +33,8 @@ import {
|
||||
runIdempotentOperation,
|
||||
startServer,
|
||||
stopServer,
|
||||
} from "./server.mjs";
|
||||
import { isValidConfig } from "./state.mjs";
|
||||
} = await import("./server.mjs");
|
||||
const { isValidConfig } = await import("./state.mjs");
|
||||
|
||||
// Minimal request stub: only headers matter to the gate.
|
||||
function req(headers) {
|
||||
@@ -99,11 +106,68 @@ test("Sec-Fetch-Site: same-origin (no Origin) is allowed", () => {
|
||||
|
||||
test("state-changing and OAuth status routes require a capability token", () => {
|
||||
assert.equal(requiresCapabilityToken("/api/install"), true);
|
||||
assert.equal(requiresCapabilityToken("/api/signin"), true);
|
||||
assert.equal(requiresCapabilityToken("/api/signin/status"), true);
|
||||
assert.equal(requiresCapabilityToken("/api/signin/cancel"), true);
|
||||
assert.equal(requiresCapabilityToken("/oauth-status"), true);
|
||||
assert.equal(requiresCapabilityToken("/auth/callback/conn"), true);
|
||||
assert.equal(requiresCapabilityToken("/setup"), false);
|
||||
});
|
||||
|
||||
test("subscription and sign-in status endpoints expose the unauthenticated state", async (t) => {
|
||||
const instanceId = `auth-routes-${Date.now()}`;
|
||||
t.after(() => stopServer(instanceId));
|
||||
const entry = await startServer(instanceId);
|
||||
const headers = { "x-connector-namespace-token": entry.token };
|
||||
|
||||
const subscriptions = await fetch(`${entry.url}api/subscriptions`, { headers });
|
||||
assert.deepEqual(await subscriptions.json(), {
|
||||
ok: false,
|
||||
reason: "not_signed_in",
|
||||
subscriptions: [],
|
||||
});
|
||||
|
||||
const status = await fetch(`${entry.url}api/signin/status?sessionId=missing`, { headers });
|
||||
assert.deepEqual(await status.json(), { ok: false, status: "unknown" });
|
||||
|
||||
const cancelled = await fetch(`${entry.url}api/signin/cancel`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ sessionId: "missing" }),
|
||||
});
|
||||
assert.deepEqual(await cancelled.json(), { ok: true });
|
||||
});
|
||||
|
||||
test("unauthenticated setup renders browser sign-in instead of an az login dead end", async (t) => {
|
||||
const instanceId = `auth-setup-${Date.now()}`;
|
||||
t.after(() => stopServer(instanceId));
|
||||
const entry = await startServer(instanceId);
|
||||
|
||||
const response = await fetch(entry.url);
|
||||
const html = await response.text();
|
||||
assert.match(html, /id="signin-btn"/);
|
||||
assert.match(html, /Sign in to Azure/);
|
||||
assert.doesNotMatch(html, /az login|Azure CLI authentication failed/);
|
||||
});
|
||||
|
||||
test("saved namespace prompts for sign-in after the extension credential is reset", async (t) => {
|
||||
const instanceId = `saved-auth-setup-${Date.now()}`;
|
||||
t.after(() => stopServer(instanceId));
|
||||
const entry = await startServer(instanceId, {
|
||||
config: {
|
||||
subscriptionId: "f34b22a3-2202-4fb1-b040-1332bd928c84",
|
||||
resourceGroup: "jack-sandboxgroup-rg",
|
||||
gatewayName: "yeah-github-cli",
|
||||
},
|
||||
});
|
||||
|
||||
const response = await fetch(entry.url);
|
||||
const html = await response.text();
|
||||
assert.match(html, /id="signin-btn"/);
|
||||
assert.match(html, /Sign in to Azure/);
|
||||
assert.doesNotMatch(html, /Couldn't load the saved namespace|couldn't open namespace/i);
|
||||
});
|
||||
|
||||
test("capability token accepts the private header or OAuth callback query", () => {
|
||||
const token = "secret-token";
|
||||
assert.equal(
|
||||
|
||||
@@ -9,23 +9,33 @@ connect → initialize → tools/list → a safe tools/call
|
||||
|
||||
It imports the `connector-namespaces` extension's real pipeline (`install.mjs`,
|
||||
`catalog.mjs`, `armClient.mjs`) and connects through the same native Streamable
|
||||
HTTP endpoint that the extension writes to the Copilot CLI config. The probe
|
||||
HTTP endpoint that the extension writes to the GitHub Copilot MCP config. The probe
|
||||
uses the configured `X-API-Key`, follows `Mcp-Session-Id`, and accepts standard
|
||||
JSON or SSE JSON-RPC responses.
|
||||
|
||||
The whole point: it runs with **Node and Azure CLI**. No Copilot app, no
|
||||
canvas, no UI. Hand it to anyone (e.g. Arjun) and they can reproduce an MCP
|
||||
server issue locally.
|
||||
The whole point: it runs with **Node and a browser sign-in**. No Copilot app or
|
||||
canvas is required. Hand it to anyone (e.g. Arjun) and they can reproduce an
|
||||
MCP server issue locally.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **Azure CLI signed in with `az login`.** The harness asks Azure CLI for the
|
||||
same short-lived ARM token as the extension.
|
||||
1. **A browser for Microsoft Entra sign-in.** The harness opens the same
|
||||
interactive Azure sign-in as the extension when its encrypted Azure Identity
|
||||
session is not already available.
|
||||
2. **A gateway already picked once.** The harness reads gateway coordinates from
|
||||
`~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json`
|
||||
(`{ subscriptionId, resourceGroup, gatewayName }`). Pick a gateway once in
|
||||
the connector-namespaces canvas, or write that file by hand.
|
||||
3. **Node 20+** (developed on Node 24).
|
||||
3. **An operating-system secure credential store.** Windows and macOS provide one
|
||||
by default. Linux and WSL require a Secret Service-compatible keyring, such as
|
||||
GNOME Keyring, with `libsecret` available. Unencrypted token storage is
|
||||
intentionally disabled.
|
||||
4. **Node 20+** (developed on Node 24).
|
||||
5. **Extension dependencies installed.** From the repository root, run:
|
||||
|
||||
```bash
|
||||
npm install --prefix extensions/connector-namespaces
|
||||
```
|
||||
|
||||
## Run it
|
||||
|
||||
@@ -52,10 +62,12 @@ node extensions/connector-namespaces/test/smoke.mjs --only=WorkIQMail,WorkIQShar
|
||||
node extensions/connector-namespaces/test/smoke.mjs --limit=5 --open-consent
|
||||
```
|
||||
|
||||
## One-time consent, then headless forever
|
||||
## One-time connector consent
|
||||
|
||||
This is the key behavior. OAuth-backed servers (most of them) need a human to
|
||||
consent **once** in a browser. The model:
|
||||
OAuth-backed servers (most of them) need a human to consent **once** in a
|
||||
browser. Azure ARM sign-in is restored from the operating system's encrypted
|
||||
credential store described in the prerequisites; the one-time behavior below
|
||||
applies to the connector's own consent. The model:
|
||||
|
||||
1. **First run** hits a server that needs consent → the harness prints a consent
|
||||
URL and marks it `NEEDS_CONSENT`. It saves a pending record to
|
||||
@@ -67,8 +79,8 @@ consent **once** in a browser. The model:
|
||||
loopback page is just a redirect target and nothing is listening on it.
|
||||
3. **Re-run the harness.** It sees the pending record, confirms the gateway
|
||||
connection is now `Connected`, finishes the install (mints the API key,
|
||||
writes the CLI entry), and probes it headless. From then on it's reused with
|
||||
zero human interaction.
|
||||
writes the Copilot MCP entry), and probes it headless. From then on the connector is
|
||||
reused without repeating its consent.
|
||||
|
||||
So the server taxonomy is:
|
||||
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
// (install.mjs, catalog.mjs, armClient.mjs) and connects through the same native
|
||||
// Streamable HTTP endpoint persisted for the Copilot CLI.
|
||||
//
|
||||
// Runs with `node` and a signed-in Azure CLI — no Copilot app required — so it
|
||||
// can be handed to someone else to reproduce MCP issues. See README.md.
|
||||
// Runs with `node` and an interactive browser sign-in — no Copilot app required
|
||||
// — so it can be handed to someone else to reproduce MCP issues. See README.md.
|
||||
//
|
||||
// Usage:
|
||||
// node extensions/connector-namespaces/test/smoke.mjs [options]
|
||||
@@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import { loadSavedConfig } from "../state.mjs";
|
||||
import { getToken } from "../armClient.mjs";
|
||||
import { cancelSignIn, getSignInStatus, isAuthenticationRequiredError, startSignIn } from "../auth.mjs";
|
||||
import { CATEGORY } from "../categories.mjs";
|
||||
import {
|
||||
installConnector,
|
||||
@@ -115,6 +116,31 @@ function logLine(text) {
|
||||
return redact(String(text)).replace(/[\r\n]+/g, " ");
|
||||
}
|
||||
|
||||
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
async function signInToAzure() {
|
||||
try {
|
||||
return await getToken();
|
||||
} catch (error) {
|
||||
if (!isAuthenticationRequiredError(error)) throw error;
|
||||
}
|
||||
const started = startSignIn();
|
||||
if (!started.ok || !started.sessionId) {
|
||||
throw new Error(started.error || "Could not start Azure sign-in.");
|
||||
}
|
||||
console.log(`${C.dim}Complete Azure sign-in in the browser window...${C.reset}`);
|
||||
for (let poll = 0; poll < 240; poll++) {
|
||||
const status = getSignInStatus(started.sessionId);
|
||||
if (status.status === "done") return getToken();
|
||||
if (status.status === "error" || status.status === "cancelled" || status.status === "unknown") {
|
||||
throw new Error(status.error || `Azure sign-in ${status.status}.`);
|
||||
}
|
||||
await sleep(2500);
|
||||
}
|
||||
cancelSignIn(started.sessionId);
|
||||
throw new Error("Azure sign-in timed out.");
|
||||
}
|
||||
|
||||
const C = {
|
||||
reset: "\x1b[0m", dim: "\x1b[2m", bold: "\x1b[1m",
|
||||
green: "\x1b[32m", red: "\x1b[31m", yellow: "\x1b[33m", cyan: "\x1b[36m",
|
||||
@@ -124,7 +150,7 @@ const tick = (ok) => (ok ? `${C.green}PASS${C.reset}` : `${C.red}FAIL${C.reset}`
|
||||
async function main() {
|
||||
const opts = parseArgs(process.argv.slice(2));
|
||||
|
||||
// 1. Bootstrap: gateway coords + ARM token (fail fast).
|
||||
// 1. Bootstrap: gateway coords + interactive ARM token (fail fast).
|
||||
const config = loadSavedConfig();
|
||||
if (!config?.subscriptionId || !config?.resourceGroup || !config?.gatewayName) {
|
||||
console.error(`${C.red}No gateway config found.${C.reset} Expected ${join(ARTIFACTS_DIR, "gateway-config.json")}.`);
|
||||
@@ -132,9 +158,9 @@ async function main() {
|
||||
process.exit(2);
|
||||
}
|
||||
try {
|
||||
await getToken();
|
||||
await signInToAzure();
|
||||
} catch (err) {
|
||||
console.error(`${C.red}Could not get an ARM token.${C.reset} Sign in to Azure when the browser opens.`);
|
||||
console.error(`${C.red}Could not get an ARM token.${C.reset}`);
|
||||
console.error(String(err.message || err).slice(0, 300));
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user