Add persistent browser auth to connector canvas

Replace Azure CLI-only authentication with InteractiveBrowserCredential, protected sign-in lifecycle endpoints, and subscription refresh after sign-in. Persist the Azure Identity cache securely across extension reloads and align connector restart guidance with the GitHub Copilot app.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Alex Yang (DevDiv)
2026-07-20 13:04:18 -07:00
parent 9ae2bdc8b7
commit 2f258c7226
16 changed files with 945 additions and 258 deletions
+1 -1
View File
@@ -245,7 +245,7 @@
"name": "connector-namespaces",
"source": "extensions/connector-namespaces",
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
"version": "1.1.2"
"version": "1.2.0"
},
{
"name": "context-engineering",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "connector-namespaces",
"description": "Browse, connect, and open MCP connectors from an Azure Connector Namespace.",
"version": "1.1.2",
"version": "1.2.0",
"author": {
"name": "Alex Yang",
"url": "https://github.com/alexyaang"
+19 -12
View File
@@ -6,14 +6,16 @@ session. Search by name or category, sign in to a connector, then restart the
session to make its tools available to the agent.
> The canvas talks to public Azure Resource Manager (`management.azure.com`)
> using the signed-in Azure CLI account. The extension does not register its own
> Entra application or persist Azure credentials.
> using an interactive Microsoft Entra browser sign-in. It does not register its
> own Entra application. Azure Identity persists the account session in the
> operating system's encrypted credential store.
## 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.
- A browser for Microsoft Entra sign-in. The extension prompts when Azure
authentication is required and restores the encrypted Azure Identity session
after extension or Copilot CLI restarts.
- **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
@@ -41,14 +43,15 @@ The destination **scope** is chosen at install time:
## Usage
1. Open the **MCP Connectors** canvas from Copilot CLI.
2. The canvas loads subscriptions from your signed-in Azure CLI account. Pick an
2. If prompted, choose **Sign in** and complete Microsoft Entra authentication
in the browser. The canvas reloads your subscriptions automatically. 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.
5. Restart the GitHub Copilot app so the agent can load the connected tools.
The extension registers the native `connector_namespaces_open_playground` tool,
so GitHub Copilot can open a named connector from **My MCPs** without installing
@@ -59,9 +62,11 @@ an additional Agent Skill.
- `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).
the canvas UI and the JSON/authentication endpoints the iframe calls.
- `auth.mjs``InteractiveBrowserCredential` broker for sign-in lifecycle,
cancellation, encrypted token-cache persistence, and ARM token refresh.
- `armClient.mjs` — thin ARM client (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).
@@ -71,9 +76,11 @@ an additional Agent Skill.
## Privacy & security
- ARM tokens come from `az account get-access-token`, stay in process memory,
and are never logged or written by the extension. Azure CLI owns sign-in and
credential storage.
- ARM tokens come from `@azure/identity`'s `InteractiveBrowserCredential` and
are never logged. Azure Identity stores its refreshable account cache through
the operating system's encrypted credential store. A non-secret account
locator is saved with user-only permissions so a new process can select that
cache entry; the extension never writes raw tokens to its artifact files.
- 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.
+12 -111
View File
@@ -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);
+268
View File
@@ -0,0 +1,268 @@
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);
async function loadAuthenticationRecord() {
try {
const serialized = await fs.readFile(AUTH_RECORD_FILE, "utf-8");
return deserializeAuthenticationRecord(serialized);
} catch (error) {
if (error?.code === "ENOENT") return undefined;
throw error;
}
}
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) {
this.cleanupInFlight = Promise.resolve().then(() => this.cleanupLegacyCredentials());
}
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" };
this.sessions.delete(sessionId);
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) 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;
let credential = this.credential;
if (!credential) {
try {
const authenticationRecord = await this.loadAuthRecord();
credential = this.createInteractiveCredential(authenticationRecord);
this.credential = credential;
} catch (error) {
throw new ConnectorAuthenticationRequiredError(
"Azure sign-in is unavailable. Check the secure token cache installation and try again.",
{ cause: error },
);
}
}
const request = credential.getToken(this.scope)
.then((accessToken) => {
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 (this.credential === credential) {
this.credential = null;
this.accessToken = null;
}
throw new ConnectorAuthenticationRequiredError(
"Sign in to Azure to continue.",
{ cause: error },
);
})
.finally(() => {
if (this.tokenInFlight === request) this.tokenInFlight = null;
});
this.tokenInFlight = request;
return request;
}
}
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,198 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import {
ConnectorAuthenticationRequiredError,
InteractiveAuthBroker,
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("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.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("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() {
throw new Error("getToken should not run after cancellation");
},
};
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" });
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",
});
});
@@ -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.
+3 -1
View File
@@ -1,6 +1,6 @@
{
"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.",
@@ -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:
+208 -27
View File
@@ -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 class="create-row">
<button id="create-ns-btn" class="create-link" type="button"><span class="plus">+</span><span>New connector namespace</span></button>
<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 style="margin-bottom: 1rem;">
<label for="sub-select">Subscription</label>
<select id="sub-select">
<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 id="setup-content">
<div class="create-row">
<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"${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">${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);">Couldn\\'t 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 = "Couldn\\'t 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
@@ -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 () => {
+63 -25
View File
@@ -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.setHeader("Content-Type", "text/html; charset=utf-8");
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}`));
res.setHeader("Content-Type", "text/html; charset=utf-8");
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(
+13 -10
View File
@@ -13,14 +13,15 @@ HTTP endpoint that the extension writes to the Copilot CLI 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
@@ -52,10 +53,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 when available; 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 +70,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 CLI entry), and probes it headless. From then on the connector is
reused without repeating its consent.
So the server taxonomy is:
+31 -5
View File
@@ -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);
}