chore: publish from main

This commit is contained in:
github-actions[bot]
2026-07-20 23:12:00 +00:00
parent 507770f2c9
commit e18018d4d7
17 changed files with 1169 additions and 315 deletions
+2 -2
View File
@@ -244,8 +244,8 @@
{ {
"name": "connector-namespaces", "name": "connector-namespaces",
"source": "extensions/connector-namespaces", "source": "extensions/connector-namespaces",
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.", "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.",
"version": "1.1.2" "version": "1.2.0"
}, },
{ {
"name": "context-engineering", "name": "context-engineering",
+2 -2
View File
@@ -1,7 +1,7 @@
{ {
"name": "connector-namespaces", "name": "connector-namespaces",
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.", "description": "Interactive GitHub Copilot canvas for discovering, connecting, and managing hosted MCP servers from Azure Connector Namespace.",
"version": "1.1.2", "version": "1.2.0",
"author": { "author": {
"name": "Alex Yang", "name": "Alex Yang",
"url": "https://github.com/alexyaang" "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 A GitHub Copilot app canvas extension for discovering and connecting hosted MCP
connectors from an Azure **Connector Namespace** directly inside a Copilot CLI servers from [Azure Connector Namespace](https://learn.microsoft.com/en-us/azure/connector-namespace/connector-namespace-overview).
session. Search by name or category, sign in to a connector, then restart the It brings the Microsoft and partner connector catalog, guided browser sign-in,
session to make its tools available to the agent. and connected-server management into the Copilot side panel.
> The canvas talks to public Azure Resource Manager (`management.azure.com`) ## Features
> using the signed-in Azure CLI account. The extension does not register its own
> Entra application or persist Azure credentials.
## Prerequisites - **Connector catalog** - browse and search Microsoft and partner MCP servers
available in your namespace.
- **GitHub Copilot CLI** (the host that loads canvas extensions). - **Guided Azure setup** - sign in from the canvas, then choose a subscription
- **Azure CLI**, signed in with `az login`. The extension asks Azure CLI for a and Connector Namespace.
short-lived ARM access token and refreshes it through the same broker. - **Browser-based connection flow** - complete each connector's authentication
- **An Azure subscription with a Connector Namespace** — resource type or consent without leaving the setup experience.
`Microsoft.Web/connectorGateways` (API version `2026-05-01-preview`). This is - **My MCPs** - see which servers are connected and ready to add to Copilot.
a preview resource provider; you must have access to it for the catalog to - **Namespace playground** - open any connected server in the Connector
load. Without it the extension installs fine but has nothing to show. Namespace playground with **Sandbox**.
- **Persistent setup** - retain the selected namespace and restore Azure sign-in
securely across app restarts.
## Install ## 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**.
``` You can also open the
install_extension https://github.com/github/awesome-copilot/tree/main/extensions/connector-namespaces [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 ## Requirements
repository.
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 Connector Namespace is currently an Azure preview service and availability can
`$COPILOT_HOME/extensions/connector-namespaces/`. The usual choice for a vary by region.
personal tool.
- **project** — installs into the current repo.
- **session** — scoped to a single CLI session.
## Usage ## Usage
1. Open the **MCP Connectors** canvas from Copilot CLI. 1. Open the **MCP Connectors** canvas in the GitHub Copilot app.
2. The canvas loads subscriptions from your signed-in Azure CLI account. Pick an 2. Select **Sign in to Azure** and complete Microsoft Entra authentication in
Azure **subscription** and a **Connector Namespace**. The choice is saved for your browser.
future sessions (change it any time via **Change namespace**). 3. Choose an Azure subscription and Connector Namespace.
3. Browse or filter the connector catalog, then **Connect**. A browser tab 4. Browse or search the catalog, then select **Connect** on an MCP server.
opens for Microsoft sign-in; complete it and the canvas updates on its own. 5. Complete the connector's sign-in or consent flow when prompted.
4. Connected connectors move into **My MCPs**. Use **Sandbox** on a tile to open 6. Confirm the server appears under **My MCPs**.
that server directly in the namespace MCP playground. 7. Restart the GitHub Copilot app so the new tools become available to the
5. Restart the Copilot CLI session so the agent can load the connected tools. agent.
The extension registers the native `connector_namespaces_open_playground` tool, Use **Sandbox** on a connected server to inspect it in the Connector Namespace
so GitHub Copilot can open a named connector from **My MCPs** without installing playground. Use **Change namespace** to switch subscriptions or namespaces.
an additional Agent Skill.
## How it works ## Authentication and privacy
- `extension.mjs` — entry point; declares the canvas, `open_sandbox` action, and Azure sign-in and connector sign-in are separate:
native `connector_namespaces_open_playground` tool.
- `server.mjs` — a loopback HTTP server (bound to `127.0.0.1` only) that serves
the canvas UI and the JSON/OAuth endpoints the iframe calls.
- `armClient.mjs` — thin ARM client (token brokered by Azure CLI, public ARM
base only, SSRF-guarded path segments).
- `catalog.mjs` — fetches and curates the connector list for a namespace.
- `install.mjs` — the connect/install pipeline (managed-API connection, consent,
rollback on cancel, and native HTTPS MCP config registration).
- `renderer.mjs` — all canvas HTML/CSS/client JS.
- `sandbox.mjs` — builds namespace playground links and resolves named My MCPs.
- `state.mjs` — saved namespace and connector state.
## Privacy & security - **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, The canvas serves its interface from loopback only (`127.0.0.1`). Azure
and are never logged or written by the extension. Azure CLI owns sign-in and management requests are restricted to `https://management.azure.com/`.
credential storage. The gateway API key that lets Copilot reach a connected server is stored in the
- All servers bind to loopback (`127.0.0.1`) and are never exposed externally. user-scoped GitHub Copilot MCP configuration and sent only to that server's
- ARM requests go only to `https://management.azure.com/`; path segments are configured HTTPS endpoint.
validated to prevent SSRF-style host smuggling.
- The minted gateway API key is stored in the selected Copilot MCP config and
sent to its validated HTTPS endpoint as the `X-API-Key` header.
## License ## 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 { constants as fsConstants, promises as fs } from "node:fs";
import { homedir, platform } from "node:os"; import { platform } from "node:os";
import { basename, delimiter, dirname, isAbsolute, join, resolve, sep } from "node:path"; import { basename, isAbsolute, join, resolve, sep } from "node:path";
import { promisify } from "node:util"; import { getToken } from "./auth.mjs";
export { getToken };
const API_VERSION = "2026-05-01-preview"; const API_VERSION = "2026-05-01-preview";
const RG_API_VERSION = "2021-04-01"; const RG_API_VERSION = "2021-04-01";
const MSI_API_VERSION = "2023-01-31"; const MSI_API_VERSION = "2023-01-31";
const SUBS_API_VERSION = "2020-01-01"; 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) { function windowsSystemExecutable(name) {
const systemRoot = process.env.SystemRoot; const systemRoot = process.env.SystemRoot;
if (systemRoot && isAbsolute(systemRoot)) return join(systemRoot, "System32", name); if (systemRoot && isAbsolute(systemRoot)) return join(systemRoot, "System32", name);
@@ -92,29 +47,6 @@ async function trustedExecutablePath(path, expectedName, workspaceRoot = process
return candidate; 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()) { export async function resolveSystemExecutable(name, workspaceRoot = process.cwd()) {
const candidates = platform() === "win32" const candidates = platform() === "win32"
? [windowsSystemExecutable(name)] ? [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}.`); 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. * List all enabled Azure subscriptions the user has access to.
*/ */
@@ -170,9 +67,13 @@ export async function getToken() {
let s_subsCache = null; // { subs, expiresAt } let s_subsCache = null; // { subs, expiresAt }
const SUBS_TTL_MS = 30 * 60 * 1000; 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(); 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 token = await getToken();
const url = `https://management.azure.com/subscriptions?api-version=${SUBS_API_VERSION}`; const url = `https://management.azure.com/subscriptions?api-version=${SUBS_API_VERSION}`;
const raw = await paginateAll(url, token); 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({ createCanvas({
id: "connector-namespaces", id: "connector-namespaces",
displayName: "MCP Connectors", 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: { inputSchema: {
type: "object", type: "object",
properties: { properties: {
@@ -12,26 +12,18 @@
import { test, after } from "node:test"; import { test, after } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { chmodSync, existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { existsSync, mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, unlinkSync, writeFileSync } from "node:fs";
import { delimiter, join } from "node:path"; import { join } from "node:path";
import { tmpdir } from "node:os"; import { tmpdir } from "node:os";
// Isolate COPILOT_HOME before importing install.mjs because its paths are bound at // 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 // module-eval time. Seed the interactive broker with an in-memory token so ARM
// seed a profile config so the local entry reads as inCli. // calls stay offline, and seed a profile config so the local entry reads as inCli.
const TMP = mkdtempSync(join(tmpdir(), "cn-reauth-")); const TMP = mkdtempSync(join(tmpdir(), "cn-reauth-"));
process.env.COPILOT_HOME = TMP; process.env.COPILOT_HOME = TMP;
process.env.USERPROFILE = TMP; // homedir() on Windows process.env.USERPROFILE = TMP; // homedir() on Windows
process.env.HOME = TMP; // homedir() on posix 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"); const legacyAuthCache = join(TMP, "extensions", "connector-namespaces", "artifacts", "auth-cache.json");
mkdirSync(join(TMP, "extensions", "connector-namespaces", "artifacts"), { recursive: true }); mkdirSync(join(TMP, "extensions", "connector-namespaces", "artifacts"), { recursive: true });
writeFileSync(legacyAuthCache, JSON.stringify({ accessToken: "legacy", refreshToken: "legacy" })); writeFileSync(legacyAuthCache, JSON.stringify({ accessToken: "legacy", refreshToken: "legacy" }));
@@ -41,6 +33,14 @@ writeFileSync(
JSON.stringify({ mcpServers: { "docusign-bbb": { type: "http", url: "https://example/mcp" } } }), 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 // 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 // and evaluate install.mjs (binding the paths to the real home) before the env
// assignments run. // assignments run.
@@ -1,9 +1,9 @@
{ {
"name": "connector-namespaces", "name": "connector-namespaces",
"version": "1.1.0", "version": "1.2.0",
"type": "module", "type": "module",
"main": "extension.mjs", "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": [ "keywords": [
"azure", "azure",
"connector-namespace", "connector-namespace",
@@ -14,6 +14,8 @@
], ],
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@azure/identity": "4.13.1",
"@azure/identity-cache-persistence": "1.3.0",
"@github/copilot-sdk": "1.0.6" "@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 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 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 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: This exists because those two bugs have each shipped multiple times:
@@ -78,6 +78,7 @@ export function baseStyles() {
} }
} }
* { box-sizing: border-box; } * { box-sizing: border-box; }
[hidden] { display: none !important; }
body { body {
font-family: "Segoe UI Variable", "Segoe UI", -apple-system, system-ui, sans-serif; font-family: "Segoe UI Variable", "Segoe UI", -apple-system, system-ui, sans-serif;
margin: 0; margin: 0;
@@ -303,7 +304,7 @@ button:focus-visible, a:focus-visible, [tabindex]:focus-visible { outline: 2px s
// Setup / Namespace Picker // Setup / Namespace Picker
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function renderSetupHtml(subscriptions, notice = "", capabilityToken = "") { export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken = "") {
const subOptions = subscriptions.map((s) => const subOptions = subscriptions.map((s) =>
`<option value="${s.id}">${esc(s.name)} (${s.id.slice(0, 8)}\u2026)</option>` `<option value="${s.id}">${esc(s.name)} (${s.id.slice(0, 8)}\u2026)</option>`
).join(""); ).join("");
@@ -336,6 +337,19 @@ export function renderSetupHtml(subscriptions, notice = "", capabilityToken = ""
} }
.create-link:hover { background: var(--accent); color: #fff; } .create-link:hover { background: var(--accent); color: #fff; }
.create-link .plus { font-size: 1.05rem; line-height: 1; font-weight: 700; } .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 { .setup-notice {
margin: 0 0 1rem; padding: .55rem .7rem; border-radius: 6px; margin: 0 0 1rem; padding: .55rem .7rem; border-radius: 6px;
background: var(--bg-pill); border: 1px solid var(--accent); 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 class="sub">Choose which connector namespace to browse. This choice is saved for future sessions.</div>
</div> </div>
${notice ? `<div class="setup-notice">${esc(notice)}</div>` : ""} ${notice ? `<div class="setup-notice">${esc(notice)}</div>` : ""}
<div class="create-row"> <div id="signin-panel" class="signin-panel" data-state="idle" aria-busy="false" hidden>
<button id="create-ns-btn" class="create-link" type="button"><span class="plus">+</span><span>New connector namespace</span></button> <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>
<div style="margin-bottom: 1rem;"> <div id="setup-content">
<label for="sub-select">Subscription</label> <div class="create-row">
<select id="sub-select"> <button id="create-ns-btn" class="create-link" type="button"${subscriptions.length ? "" : " disabled"}><span class="plus">+</span><span>New connector namespace</span></button>
<option value="">-- Select subscription --</option> </div>
${subOptions} <div style="margin-bottom: 1rem;">
</select> <label for="sub-select">Subscription</label>
</div> <select id="sub-select"${subscriptions.length ? "" : " disabled"}>
<input id="gw-filter" type="text" placeholder="Filter namespaces by name\u2026" autocomplete="off" spellcheck="false"> <option value="">-- Select subscription --</option>
<div id="gateway-list"> ${subOptions}
<div class="empty">Select a subscription to see available connector namespaces.</div> </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">${subscriptions.length ? "Select a subscription to see available connector namespaces." : "Loading subscriptions\u2026"}</div>
</div>
</div> </div>
<script> <script>
const connectorNamespaceToken = ${JSON.stringify(capabilityToken)}; const connectorNamespaceToken = ${JSON.stringify(capabilityToken)};
@@ -379,7 +402,15 @@ window.fetch = (input, init = {}) => {
const subSelect = document.getElementById("sub-select"); const subSelect = document.getElementById("sub-select");
const gatewayList = document.getElementById("gateway-list"); const gatewayList = document.getElementById("gateway-list");
const gwFilter = document.getElementById("gw-filter"); 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) : ""); window.location.href = "/create" + (subSelect.value ? "?subscriptionId=" + encodeURIComponent(subSelect.value) : "");
}); });
let allGateways = []; let allGateways = [];
@@ -387,6 +418,158 @@ let hasMoreGateways = false;
let loadedAll = false; let loadedAll = false;
let gatewayRequestSeq = 0; 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 () => { subSelect.addEventListener("change", async () => {
const requestSeq = ++gatewayRequestSeq; const requestSeq = ++gatewayRequestSeq;
const subId = subSelect.value; const subId = subSelect.value;
@@ -507,6 +690,9 @@ async function selectGateway(subscriptionId, resourceGroup, gatewayName) {
if (data.ok) { window.location.href = "/"; } if (data.ok) { window.location.href = "/"; }
else { gatewayList.innerHTML = '<div class="empty" style="color:var(--danger);">Failed to save.</div>'; } 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>`; </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 { 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); } .restart-banner .rb-dismiss:hover { color:var(--accent); background:var(--bg-hover); }
.is-hidden { display:none !important; } .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 ---- */ /* ---- split "remove" control + its popover menu + delete-confirm dialog ---- */
/* main + caret read as one pill; the shared 1px border between them is the /* 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>
<div id="restart-banner" class="restart-banner" role="status" hidden> <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> <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> <button class="rb-dismiss" type="button" aria-label="Dismiss this message">Dismiss</button>
</div> </div>
${sectionsHtml} ${sectionsHtml}
@@ -866,9 +1047,9 @@ if (input.value) applyFilters();
// for now because it writes a plaintext API key into a git-tracked .mcp.json. // for now because it writes a plaintext API key into a git-tracked .mcp.json.
const installScope = "profile"; 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 // 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. // extension process and clears it, so the banner can't go stale.
const restartBanner = document.getElementById("restart-banner"); const restartBanner = document.getElementById("restart-banner");
// Once the user dismisses the banner, a late/racing hydrateState (its // Once the user dismisses the banner, a late/racing hydrateState (its
@@ -1068,7 +1249,7 @@ function openSignInModal(displayName, consentUrl) {
cancellable = false; cancellable = false;
icon.innerHTML = '<div class="si-check">\u2713</div>'; icon.innerHTML = '<div class="si-check">\u2713</div>';
title.textContent = "Connected"; 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 = ""; meta.textContent = "";
}, },
close() { doClose(); }, close() { doClose(); },
@@ -1116,7 +1297,7 @@ async function onConnect(btn) {
pendingConn = null; 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) { } catch (err) {
const recovery = await recoverConnectorFailure( const recovery = await recoverConnectorFailure(
err, err,
@@ -1128,7 +1309,7 @@ async function onConnect(btn) {
true true
); );
if (recovery.complete) { 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; return;
} }
if (modal) modal.close(); if (modal) modal.close();
@@ -1218,7 +1399,7 @@ async function onReauth(btn) {
await showConnectionSuccess( await showConnectionSuccess(
modal, modal,
item, 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) { } catch (err) {
const recovery = await recoverConnectorFailure( const recovery = await recoverConnectorFailure(
@@ -1234,7 +1415,7 @@ async function onReauth(btn) {
await showConnectionSuccess( await showConnectionSuccess(
modal, modal,
item, 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; return;
} }
@@ -7,7 +7,7 @@
// loaders without a visible fallback. Reduced motion now stops the // loaders without a visible fallback. Reduced motion now stops the
// animation while forcing each loader into a visible static busy state; // animation while forcing each loader into a visible static busy state;
// nearby text continues to communicate progress. // 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 // root cause was CSS specificity: `.restart-banner{display:flex}` is an
// author rule with the same (0,1,0) specificity as the UA // author rule with the same (0,1,0) specificity as the UA
// `[hidden]{display:none}` rule, so it overrode the hidden attribute and // `[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>/); 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", () => { test("load-all and installed-state failures stay visible and fail closed", () => {
const setup = renderSetupHtml([], "", "token"); const setup = renderSetupHtml([], "", "token");
const catalog = catalogHtml(); 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", () => { test("a global [hidden] reset makes the hidden attribute authoritative", () => {
// The actual dismiss bug: .restart-banner{display:flex} (an author rule) // The actual dismiss bug: .restart-banner{display:flex} (an author rule)
// ties the UA [hidden]{display:none} rule on specificity and wins, so the // ties the UA [hidden]{display:none} rule on specificity and wins, so the
@@ -1,10 +1,8 @@
import { test } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { mkdtemp, mkdir, readFile, realpath, rm, writeFile } from "node:fs/promises"; import { readFile } from "node:fs/promises";
import { delimiter, join } from "node:path";
import { tmpdir } from "node:os";
import { armSegment, parseAzureCliToken, resolvePosixAzureCli, waitForProvisioning } from "./armClient.mjs"; import { armSegment, waitForProvisioning } from "./armClient.mjs";
const here = new URL(".", import.meta.url); 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 () => { test("Azure authentication uses an interactive browser and persistent encrypted cache", async () => {
const source = await readFile(new URL("armClient.mjs", here), "utf8"); const [authSource, armSource] = await Promise.all([
assert.match(source, /account get-access-token --resource/); readFile(new URL("auth.mjs", here), "utf8"),
assert.doesNotMatch(source, /04b07795-8ddb-461a-bbee-02f9e1bf7b46/); readFile(new URL("armClient.mjs", here), "utf8"),
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 }),
]); ]);
await Promise.all([ assert.match(authSource, /new InteractiveBrowserCredential\(options\)/);
writeFile(join(workspaceBin, "az"), "workspace", { mode: 0o755 }), assert.match(authSource, /useIdentityPlugin\(cachePersistencePlugin\)/);
writeFile(join(trustedBin, "az"), "trusted", { mode: 0o755 }), assert.match(authSource, /disableAutomaticAuthentication: true/);
]); assert.match(authSource, /tokenCachePersistenceOptions/);
try { assert.match(authSource, /credential\.authenticate\(\s*this\.scope,\s*\{ abortSignal/);
const resolved = await resolvePosixAzureCli( assert.match(authSource, /serializeAuthenticationRecord/);
[workspaceBin, trustedBin].join(delimiter), assert.doesNotMatch(armSource, /get-access-token|az login|resolvePosixAzureCli/);
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 });
}
}); });
test("installer preserves capability tokens and persists direct HTTP entries", async () => { test("installer preserves capability tokens and persists direct HTTP entries", async () => {
@@ -9,6 +9,7 @@ import { fetchCatalog, invalidateCache } from "./catalog.mjs";
import { import {
listConnectorGateways, listConnectorGateways,
listSubscriptions, listSubscriptions,
invalidateSubscriptionsCache,
listResourceGroups, listResourceGroups,
listUserAssignedIdentities, listUserAssignedIdentities,
checkConnectorGatewayNameAvailable, checkConnectorGatewayNameAvailable,
@@ -16,6 +17,12 @@ import {
createConnectorGateway, createConnectorGateway,
buildGatewayIdentity, buildGatewayIdentity,
} from "./armClient.mjs"; } 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"; import { installConnector, finishInstall, reauthConnector, finishReauth, openInBrowser, openMcpConfigFile, getInstalledState, uninstallConnector, removeLocalEntry, deleteConnection, prewarmMeta } from "./install.mjs";
const servers = new Map(); 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. // 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 // 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 // after the process started isn't usable until the app restarts. A full app
// session restart spawns a fresh process and resets this to false. `acked` // restart spawns a fresh process and resets this to false. `acked`
// lets the user dismiss the reminder for the rest of the process. // lets the user dismiss the reminder for the rest of the process.
let pendingRestart = false; let pendingRestart = false;
let restartAcked = false; let restartAcked = false;
@@ -158,6 +165,44 @@ async function handleRequest(req, res, instanceId, serverEntry) {
// --- API routes --- // --- 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") { if (req.method === "POST" && url.pathname === "/api/add") {
const body = await parseBody(req); const body = await parseBody(req);
const config = serverEntry.config; const config = serverEntry.config;
@@ -485,21 +530,19 @@ async function handleRequest(req, res, instanceId, serverEntry) {
res.end(renderCreateNamespaceHtml(subs, preselected, serverEntry.token)); res.end(renderCreateNamespaceHtml(subs, preselected, serverEntry.token));
} catch (err) { } catch (err) {
res.setHeader("Content-Type", "text/html; charset=utf-8"); 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; return;
} }
// Setup page (no gateway configured) // Setup page (no gateway configured)
if (!config || url.pathname === "/setup") { if (!config || url.pathname === "/setup") {
try { res.setHeader("Content-Type", "text/html; charset=utf-8");
const subs = await listSubscriptions(); res.end(renderSetupHtml([], "", serverEntry.token));
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}`));
}
return; return;
} }
@@ -516,20 +559,15 @@ async function handleRequest(req, res, instanceId, serverEntry) {
res.setHeader("Content-Type", "text/html; charset=utf-8"); res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderCatalogHtml(instanceId, catalog, { filter, category, source, config }, serverEntry.token)); res.end(renderCatalogHtml(instanceId, catalog, { filter, category, source, config }, serverEntry.token));
} catch (err) { } catch (err) {
// Trust-and-fallback: a saved namespace that can no longer be read res.setHeader("Content-Type", "text/html; charset=utf-8");
// (deleted, access revoked, a transient outage) should drop the user if (isAuthenticationRequiredError(err)) {
// back to the picker, not a dead-end error page. Only if even the res.end(renderSetupHtml([], "", serverEntry.token));
// picker can't load its subscriptions do we surface the raw error. } else {
try { res.end(renderSetupHtml(
const subs = await listSubscriptions(); [],
res.setHeader("Content-Type", "text/html; charset=utf-8"); `Couldn't load the saved namespace "${config.gatewayName}". Choose another namespace or try again.`,
res.end(renderSetupHtml(subs, `couldn't open namespace ${config.gatewayName} .. pick another to continue.`, serverEntry.token)); 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}`));
} }
} }
} }
@@ -10,12 +10,19 @@
// harness — through untouched. Importing server.mjs has no side effects at eval; // harness — through untouched. Importing server.mjs has no side effects at eval;
// the HTTP server only starts when startServer() is called. // 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 assert from "node:assert/strict";
import { EventEmitter } from "node:events"; 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 { 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, getServerConfig,
hasCapabilityToken, hasCapabilityToken,
isCanonicalHost, isCanonicalHost,
@@ -26,8 +33,8 @@ import {
runIdempotentOperation, runIdempotentOperation,
startServer, startServer,
stopServer, stopServer,
} from "./server.mjs"; } = await import("./server.mjs");
import { isValidConfig } from "./state.mjs"; const { isValidConfig } = await import("./state.mjs");
// Minimal request stub: only headers matter to the gate. // Minimal request stub: only headers matter to the gate.
function req(headers) { 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", () => { test("state-changing and OAuth status routes require a capability token", () => {
assert.equal(requiresCapabilityToken("/api/install"), true); 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("/oauth-status"), true);
assert.equal(requiresCapabilityToken("/auth/callback/conn"), true); assert.equal(requiresCapabilityToken("/auth/callback/conn"), true);
assert.equal(requiresCapabilityToken("/setup"), false); 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", () => { test("capability token accepts the private header or OAuth callback query", () => {
const token = "secret-token"; const token = "secret-token";
assert.equal( 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`, It imports the `connector-namespaces` extension's real pipeline (`install.mjs`,
`catalog.mjs`, `armClient.mjs`) and connects through the same native Streamable `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 uses the configured `X-API-Key`, follows `Mcp-Session-Id`, and accepts standard
JSON or SSE JSON-RPC responses. JSON or SSE JSON-RPC responses.
The whole point: it runs with **Node and Azure CLI**. No Copilot app, no The whole point: it runs with **Node and a browser sign-in**. No Copilot app or
canvas, no UI. Hand it to anyone (e.g. Arjun) and they can reproduce an MCP canvas is required. Hand it to anyone (e.g. Arjun) and they can reproduce an
server issue locally. MCP server issue locally.
## Prerequisites ## Prerequisites
1. **Azure CLI signed in with `az login`.** The harness asks Azure CLI for the 1. **A browser for Microsoft Entra sign-in.** The harness opens the same
same short-lived ARM token as the extension. 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 2. **A gateway already picked once.** The harness reads gateway coordinates from
`~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json` `~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json`
(`{ subscriptionId, resourceGroup, gatewayName }`). Pick a gateway once in (`{ subscriptionId, resourceGroup, gatewayName }`). Pick a gateway once in
the connector-namespaces canvas, or write that file by hand. 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 ## 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 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 OAuth-backed servers (most of them) need a human to consent **once** in a
consent **once** in a browser. The model: 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 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 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. 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 3. **Re-run the harness.** It sees the pending record, confirms the gateway
connection is now `Connected`, finishes the install (mints the API key, 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 writes the Copilot MCP entry), and probes it headless. From then on the connector is
zero human interaction. reused without repeating its consent.
So the server taxonomy is: So the server taxonomy is:
@@ -6,8 +6,8 @@
// (install.mjs, catalog.mjs, armClient.mjs) and connects through the same native // (install.mjs, catalog.mjs, armClient.mjs) and connects through the same native
// Streamable HTTP endpoint persisted for the Copilot CLI. // Streamable HTTP endpoint persisted for the Copilot CLI.
// //
// Runs with `node` and a signed-in Azure CLI — no Copilot app required — so it // Runs with `node` and an interactive browser sign-in — no Copilot app required
// can be handed to someone else to reproduce MCP issues. See README.md. // — so it can be handed to someone else to reproduce MCP issues. See README.md.
// //
// Usage: // Usage:
// node extensions/connector-namespaces/test/smoke.mjs [options] // node extensions/connector-namespaces/test/smoke.mjs [options]
@@ -25,6 +25,7 @@ import { fileURLToPath } from "node:url";
import { loadSavedConfig } from "../state.mjs"; import { loadSavedConfig } from "../state.mjs";
import { getToken } from "../armClient.mjs"; import { getToken } from "../armClient.mjs";
import { cancelSignIn, getSignInStatus, isAuthenticationRequiredError, startSignIn } from "../auth.mjs";
import { CATEGORY } from "../categories.mjs"; import { CATEGORY } from "../categories.mjs";
import { import {
installConnector, installConnector,
@@ -115,6 +116,31 @@ function logLine(text) {
return redact(String(text)).replace(/[\r\n]+/g, " "); 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 = { const C = {
reset: "\x1b[0m", dim: "\x1b[2m", bold: "\x1b[1m", reset: "\x1b[0m", dim: "\x1b[2m", bold: "\x1b[1m",
green: "\x1b[32m", red: "\x1b[31m", yellow: "\x1b[33m", cyan: "\x1b[36m", 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() { async function main() {
const opts = parseArgs(process.argv.slice(2)); 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(); const config = loadSavedConfig();
if (!config?.subscriptionId || !config?.resourceGroup || !config?.gatewayName) { if (!config?.subscriptionId || !config?.resourceGroup || !config?.gatewayName) {
console.error(`${C.red}No gateway config found.${C.reset} Expected ${join(ARTIFACTS_DIR, "gateway-config.json")}.`); 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); process.exit(2);
} }
try { try {
await getToken(); await signInToAzure();
} catch (err) { } 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)); console.error(String(err.message || err).slice(0, 300));
process.exit(2); process.exit(2);
} }