Improve connector reload authentication UX (#2370)

Keep Azure tokens in memory and preserve linked namespace context across extension reloads.

Co-authored-by: Alex Yang (DevDiv) <yangalex@microsoft.com>
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Alex Yang [MSFT]
2026-07-23 10:51:00 -07:00
committed by GitHub
parent 26a363779a
commit c0869f6231
10 changed files with 110 additions and 171 deletions
+9 -13
View File
@@ -16,8 +16,8 @@ and connected-server management into the Copilot side panel.
- **My MCPs** - see which servers are connected and ready to add to Copilot.
- **Namespace playground** - open any connected server in the Connector
Namespace playground with **Sandbox**.
- **Persistent setup** - retain the selected namespace and restore Azure sign-in
securely across app restarts.
- **Persistent namespace selection** - retain the selected namespace while Azure
tokens remain in memory and sign-in is requested again after a restart.
## Install
@@ -36,10 +36,6 @@ and select **Install in GitHub Copilot app**.
- 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.
Connector Namespace is currently an Azure preview service and availability can
vary by region.
@@ -64,13 +60,13 @@ playground. Use **Change namespace** to switch subscriptions or namespaces.
Azure sign-in and connector sign-in are separate:
- **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.
resources. Access and refresh tokens remain in the extension process and are
never written to extension files. Reloading the extension or restarting the
app requires Azure sign-in again. The selected namespace coordinates are
retained in
`~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json` so
the canvas can explain that the namespace is still linked and return directly
to its connectors after sign-in.
- **Connector sign-in** grants an individual MCP server access to its backing
service. The resulting connection is managed by Connector Namespace.
+16 -72
View File
@@ -2,16 +2,9 @@ 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";
import { InteractiveBrowserCredential } from "@azure/identity";
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;
@@ -24,50 +17,20 @@ const AUTH_STORAGE_DIR = join(
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;
let legacyAuthArtifactsRemoved = 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}`);
async function removeLegacyAuthArtifacts() {
if (legacyAuthArtifactsRemoved) return;
for (const path of [AUTH_RECORD_FILE, LEGACY_AUTH_CACHE]) {
try {
await fs.unlink(path);
} catch (error) {
if (error?.code !== "ENOENT") {
throw new Error(`Could not remove the legacy connector authentication file at ${path}: ${error.message}`);
}
}
}
legacyAuthCacheRemoved = true;
legacyAuthArtifactsRemoved = true;
}
export class ConnectorAuthenticationRequiredError extends Error {
@@ -103,20 +66,14 @@ export class InteractiveAuthBroker {
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;
@@ -124,15 +81,10 @@ export class InteractiveAuthBroker {
this.sessions = new Map();
}
createInteractiveCredential(authenticationRecord) {
createInteractiveCredential() {
return this.createCredential({
redirectUri: "http://localhost",
disableAutomaticAuthentication: true,
tokenCachePersistenceOptions: {
enabled: true,
name: this.tokenCacheName,
},
...(authenticationRecord ? { authenticationRecord } : {}),
});
}
@@ -188,7 +140,6 @@ export class InteractiveAuthBroker {
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.");
@@ -235,13 +186,8 @@ export class InteractiveAuthBroker {
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;
}
credential = this.createInteractiveCredential();
this.credential = credential;
} catch (error) {
if (isAuthenticationRequiredError(error)) {
throw new ConnectorAuthenticationRequiredError(
@@ -281,9 +227,7 @@ export class InteractiveAuthBroker {
}
export const interactiveAuth = new InteractiveAuthBroker({
cleanupLegacyCredentials: removeLegacyAuthCache,
loadAuthRecord: loadAuthenticationRecord,
saveAuthRecord: saveAuthenticationRecord,
cleanupLegacyCredentials: removeLegacyAuthArtifacts,
});
export const startSignIn = () => interactiveAuth.startSignIn();
+22 -59
View File
@@ -4,8 +4,6 @@ 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) {
@@ -35,7 +33,6 @@ test("ARM token requests require an explicit browser sign-in", async () => {
},
};
},
loadAuthRecord: async () => undefined,
});
await assert.rejects(
@@ -43,32 +40,13 @@ test("ARM token requests require an explicit browser sign-in", async () => {
(error) => error instanceof ConnectorAuthenticationRequiredError
&& error.code === "authentication_required",
);
assert.deepEqual(credentialOptions.tokenCachePersistenceOptions, {
enabled: true,
name: TOKEN_CACHE_NAME,
assert.deepEqual(credentialOptions, {
redirectUri: "http://localhost",
disableAutomaticAuthentication: true,
});
});
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 () => {
test("interactive sign-in reports pending then done and keeps the ARM token in memory", async () => {
let credentialOptions;
let authenticateOptions;
const credential = {
@@ -89,7 +67,6 @@ test("interactive sign-in reports pending then done and caches the ARM token", a
return credential;
},
createSessionId: () => "signin-session",
saveAuthRecord: async (record) => assert.deepEqual(record, authenticationRecord()),
now: () => 1_000,
});
@@ -110,10 +87,6 @@ test("interactive sign-in reports pending then done and caches the ARM token", a
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" });
@@ -124,22 +97,24 @@ test("interactive sign-in reports pending then done and caches the ARM token", a
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 };
test("a new broker requires browser sign-in after the extension reloads", async () => {
let authenticateCalls = 0;
let createCredentialCalls = 0;
const createCredential = (options) => {
assert.deepEqual(options.tokenCachePersistenceOptions, {
enabled: true,
name: TOKEN_CACHE_NAME,
createCredentialCalls++;
assert.deepEqual(options, {
redirectUri: "http://localhost",
disableAutomaticAuthentication: true,
});
let signedIn = false;
return {
async authenticate() {
authenticateCalls++;
cache.token = accessToken("persisted-token");
signedIn = true;
return authenticationRecord();
},
async getToken() {
if (cache.token && options.authenticationRecord) return cache.token;
if (signedIn) return accessToken("memory-token");
const error = new Error("No cached account found.");
error.name = "AuthenticationRequiredError";
throw error;
@@ -149,41 +124,36 @@ test("a new broker restores the persisted credential without reopening the brows
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);
assert.equal(await signedInBroker.getToken(), "memory-token");
const restartedBroker = new InteractiveAuthBroker({
createCredential,
loadAuthRecord: async () => cache.record,
now: () => 1_000,
});
assert.equal(await restartedBroker.getToken(), "persisted-token");
await assert.rejects(restartedBroker.getToken(), ConnectorAuthenticationRequiredError);
assert.equal(authenticateCalls, 1);
assert.equal(createCredentialCalls, 2);
});
test("concurrent first-time token requests share credential initialization and acquisition", async () => {
let releaseAuthenticationRecord;
const authenticationRecordReady = new Promise((resolve) => {
releaseAuthenticationRecord = resolve;
test("concurrent first-time token requests share credential acquisition", async () => {
let releaseToken;
const tokenReady = new Promise((resolve) => {
releaseToken = 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++;
await tokenReady;
return accessToken("shared-token");
},
};
@@ -193,15 +163,12 @@ test("concurrent first-time token requests share credential initialization and a
const firstRequest = broker.getToken();
const secondRequest = broker.getToken();
await new Promise((resolve) => setImmediate(resolve));
const loadsBeforeRelease = loadAuthRecordCalls;
releaseAuthenticationRecord();
releaseToken();
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);
});
@@ -224,7 +191,6 @@ test("cancelling sign-in aborts the credential request", async () => {
const broker = new InteractiveAuthBroker({
createCredential: () => credential,
createSessionId: () => "cancel-session",
loadAuthRecord: async () => undefined,
now: () => 1_000,
});
@@ -289,7 +255,6 @@ test("legacy credential cleanup retries after a transient failure", async () =>
return accessToken();
},
}),
loadAuthRecord: async () => authenticationRecord(),
});
await assert.rejects(broker.getToken(), /legacy cache is locked/);
@@ -312,7 +277,6 @@ test("token acquisition preserves operational errors and retries the credential"
},
};
},
loadAuthRecord: async () => authenticationRecord(),
});
await assert.rejects(broker.getToken(), (error) => error === outage);
@@ -328,7 +292,6 @@ test("incomplete tokens remain operational errors instead of prompting sign-in",
return { token: "incomplete" };
},
}),
loadAuthRecord: async () => authenticationRecord(),
});
await assert.rejects(
@@ -15,7 +15,6 @@
"license": "MIT",
"dependencies": {
"@azure/identity": "4.13.1",
"@azure/identity-cache-persistence": "1.3.0",
"@github/copilot-sdk": "1.0.6"
}
}
+27 -9
View File
@@ -304,14 +304,23 @@ 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 = "", { linkedNamespace = "" } = {}) {
const hasLinkedNamespace = typeof linkedNamespace === "string" && linkedNamespace.length > 0;
const pageTitle = hasLinkedNamespace ? "Sign in to MCP Connectors" : "Select Connector Namespace";
const heading = hasLinkedNamespace ? "Sign in to see your connectors" : "Select a Connector Namespace";
const subheading = hasLinkedNamespace
? `Connector namespace <code>${esc(linkedNamespace)}</code> is already linked.`
: "Choose which connector namespace to browse. This choice is saved for future sessions.";
const defaultSigninMessage = hasLinkedNamespace
? "Sign in to Azure to view and manage its connectors."
: "Sign in to Azure to load your subscriptions and connector namespaces.";
const subOptions = subscriptions.map((s) =>
`<option value="${s.id}">${esc(s.name)} (${s.id.slice(0, 8)}\u2026)</option>`
).join("");
return `<!doctype html><html lang="en"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Select Connector Namespace</title>${baseStyles()}
<title>${pageTitle}</title>${baseStyles()}
<style>
.skeleton { animation: pulse 1.2s ease-in-out infinite; }
@keyframes pulse { 0%,100% { opacity: .4; } 50% { opacity: .8; } }
@@ -357,18 +366,18 @@ export function renderSetupHtml(subscriptions = [], notice = "", capabilityToken
}
</style></head><body>
<div class="header brand-head">
<h1>${brandMark(30, "setup")}<span>Select a Connector Namespace</span></h1>
<div class="sub">Choose which connector namespace to browse. This choice is saved for future sessions.</div>
<h1>${brandMark(30, "setup")}<span>${heading}</span></h1>
<div class="sub">${subheading}</div>
</div>
${notice ? `<div class="setup-notice">${esc(notice)}</div>` : ""}
<div id="signin-panel" class="signin-panel" data-state="idle" aria-busy="false" hidden>
<p id="signin-message" class="signin-message" aria-live="polite">Sign in to Azure to load your subscriptions and connector namespaces.</p>
<p id="signin-message" class="signin-message" aria-live="polite">${defaultSigninMessage}</p>
<div class="signin-actions">
<button id="signin-btn" class="item-add primary signin-primary" type="button">Sign in to Azure</button>
<button id="cancel-signin-btn" class="signin-cancel" type="button" hidden>Cancel</button>
</div>
</div>
<div id="setup-content">
<div id="setup-content"${hasLinkedNamespace ? " hidden" : ""}>
<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>
@@ -386,6 +395,8 @@ ${notice ? `<div class="setup-notice">${esc(notice)}</div>` : ""}
</div>
<script>
const connectorNamespaceToken = ${JSON.stringify(capabilityToken)};
const hasLinkedNamespace = ${hasLinkedNamespace};
const defaultSigninMessage = ${JSON.stringify(defaultSigninMessage)};
const rawFetch = window.fetch.bind(window);
window.fetch = (input, init = {}) => {
const url = typeof input === "string" ? input : input && input.url;
@@ -423,7 +434,7 @@ function setSigninRequired(message, state = "idle") {
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.";
signinMessage.textContent = message || defaultSigninMessage;
signinButton.disabled = false;
signinButton.hidden = false;
cancelSigninButton.hidden = true;
@@ -530,8 +541,14 @@ async function pollSignin() {
stopSigninPolling();
signinPanel.dataset.state = "pending";
signinPanel.setAttribute("aria-busy", "true");
signinMessage.textContent = "Signed in. Loading subscriptions\u2026";
signinMessage.textContent = hasLinkedNamespace
? "Signed in. Loading your connectors\u2026"
: "Signed in. Loading subscriptions\u2026";
signinPanel.hidden = false;
if (hasLinkedNamespace) {
window.location.replace("/");
return;
}
await loadSubscriptions(true);
return;
}
@@ -691,7 +708,8 @@ async function selectGateway(subscriptionId, resourceGroup, gatewayName) {
else { gatewayList.innerHTML = '<div class="empty" style="color:var(--danger);">Failed to save.</div>'; }
}
if (${subscriptions.length > 0}) setSetupReady();
if (hasLinkedNamespace) setSigninRequired();
else if (${subscriptions.length > 0}) setSetupReady();
else loadSubscriptions(false);
</script></body></html>`;
}
@@ -67,6 +67,27 @@ test("setup prompts, polls, cancels, and reloads subscriptions after browser sig
assert.match(html, /\/api\/subscriptions/);
});
test("a linked namespace gets a focused sign-in state and returns to its catalog", () => {
const html = renderSetupHtml([], "", "token", { linkedNamespace: "saved-gateway" });
assert.match(html, /Sign in to see your connectors/);
assert.match(html, /Connector namespace <code>saved-gateway<\/code> is already linked\./);
assert.match(html, /Sign in to Azure to view and manage its connectors\./);
assert.match(html, /const hasLinkedNamespace = true/);
assert.match(html, /window\.location\.replace\("\/"\)/);
assert.match(html, /<div id="setup-content" hidden>/);
});
test("linked namespace sign-in escapes saved names and produces valid client JavaScript", () => {
const html = renderSetupHtml([], "", "token", {
linkedNamespace: `gateway</code><script>alert("xss")</script>`,
});
assert.doesNotMatch(html, /<script>alert\("xss"\)<\/script>/);
assert.match(html, /gateway&lt;\/code&gt;&lt;script&gt;alert\(&quot;xss&quot;\)&lt;\/script&gt;/);
const script = html.match(/<script>([\s\S]*)<\/script>/)?.[1];
assert.ok(script, "linked setup page must include its client script");
assert.doesNotThrow(() => new Function(script));
});
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"/);
@@ -49,17 +49,16 @@ test("namespace creation polls an empty 202 result until explicit success", asyn
);
});
test("Azure authentication uses an interactive browser and persistent encrypted cache", async () => {
test("Azure authentication uses an interactive browser with process-memory tokens", async () => {
const [authSource, armSource] = await Promise.all([
readFile(new URL("auth.mjs", here), "utf8"),
readFile(new URL("armClient.mjs", here), "utf8"),
]);
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(authSource, /identity-cache-persistence|cachePersistencePlugin|tokenCachePersistenceOptions/);
assert.doesNotMatch(authSource, /serializeAuthenticationRecord|saveAuthenticationRecord/);
assert.doesNotMatch(armSource, /get-access-token|az login|resolvePosixAzureCli/);
});
+3 -1
View File
@@ -561,7 +561,9 @@ async function handleRequest(req, res, instanceId, serverEntry) {
} catch (err) {
res.setHeader("Content-Type", "text/html; charset=utf-8");
if (isAuthenticationRequiredError(err)) {
res.end(renderSetupHtml([], "", serverEntry.token));
res.end(renderSetupHtml([], "", serverEntry.token, {
linkedNamespace: config.gatewayName,
}));
} else {
res.end(renderSetupHtml(
[],
@@ -164,7 +164,9 @@ test("saved namespace prompts for sign-in after the extension credential is rese
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.match(html, /Sign in to see your connectors/);
assert.match(html, /Connector namespace <code>yeah-github-cli<\/code> is already linked\./);
assert.match(html, /Sign in to Azure to view and manage its connectors\./);
assert.doesNotMatch(html, /Couldn't load the saved namespace|couldn't open namespace/i);
});
+6 -11
View File
@@ -20,18 +20,13 @@ MCP server issue locally.
## Prerequisites
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.
interactive Azure sign-in as the extension at the start of each process.
2. **A gateway already picked once.** The harness reads gateway coordinates from
`~/.copilot/extensions/connector-namespaces/artifacts/gateway-config.json`
(`{ subscriptionId, resourceGroup, gatewayName }`). Pick a gateway once in
the connector-namespaces canvas, or write that file by hand.
3. **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:
3. **Node 20+** (developed on Node 24).
4. **Extension dependencies installed.** From the repository root, run:
```bash
npm install --prefix extensions/connector-namespaces
@@ -65,9 +60,9 @@ node extensions/connector-namespaces/test/smoke.mjs --limit=5 --open-consent
## One-time connector consent
OAuth-backed servers (most of them) need a human to consent **once** in a
browser. Azure ARM sign-in is restored from the operating system's encrypted
credential store described in the prerequisites; the one-time behavior below
applies to the connector's own consent. The model:
browser. Azure ARM sign-in is process-memory only and is repeated for each
harness process; 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