agentHost: refresh expiring GitHub credentials (#334418)

Propagate authentication session lifetimes through AHP and use the Copilot SDK token provider for renewable Entra-backed GitHub sessions. Centralize static and provider-backed credential behavior behind one owner and reuse shared expiry helpers throughout authentication replay.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
TylerLeonhardt
2026-09-09 02:57:00 +00:00
committed by GitHub
co-authored by Copilot
parent 162c56c25e
commit b4e90b1a76
28 changed files with 743 additions and 199 deletions
@@ -20,7 +20,8 @@
"enabledApiProposals": [
"authIssuers",
"authProviderSpecific",
"authSessionAccountIcon"
"authSessionAccountIcon",
"authSessionExpiration"
],
"activationEvents": [],
"capabilities": {
+36 -16
View File
@@ -42,6 +42,8 @@ interface ITransientSession {
readonly expiresAt: number;
}
const TOKEN_RENEWAL_WINDOW_MS = 60 * 60 * 1000;
export enum AuthProviderType {
github = 'github',
githubEnterprise = 'github-enterprise'
@@ -267,7 +269,10 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
/** Every session held only by this process, whether or not its token is still any good. */
private get transientSessions(): vscode.AuthenticationSession[] {
return [...this._transientSessions.values()].map(held => held.session);
return [...this._transientSessions.values()].map(held => ({
...held.session,
expiresIn: held.expiresAt > Date.now() ? Math.ceil((held.expiresAt - Date.now()) / 1000) : undefined
}));
}
get onDidChangeSessions() {
@@ -397,8 +402,7 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
return undefined;
}
const session = this.sessionFor(renewed.account, renewed.token, [...renewed.scopes]);
this._transientSessions.set(session.id, { session, expiresAt: Date.now() + renewed.expiresIn * 1000 });
const session = this.storeTransientSession(this.sessionFor(renewed.account, renewed.token, [...renewed.scopes]), renewed.expiresIn);
this.afterSessionLoad(session);
return session;
}
@@ -422,7 +426,7 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
for (const session of wanted) {
// No transient entry means a persisted session, and nothing persisted carries an expiry.
const held = this._transientSessions.get(session.id);
if (!held || held.expiresAt > now) {
if (!held || held.expiresAt > now + TOKEN_RENEWAL_WINDOW_MS) {
usable.push(session);
} else {
stale.push(session);
@@ -437,8 +441,26 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
}
const renewed = await Promise.all(stale.map(session => this.renew(session)));
this.evict(stale.filter((_, index) => !renewed[index]), 'their token ran out and could not be renewed');
return [...usable, ...renewed.filter(<T>(session?: T): session is T => Boolean(session))];
const expired: vscode.AuthenticationSession[] = [];
const retained: vscode.AuthenticationSession[] = [];
for (let index = 0; index < stale.length; index++) {
if (renewed[index]) {
continue;
}
const held = this._transientSessions.get(stale[index].id);
const remainingLifetime = held ? held.expiresAt - Date.now() : 0;
if (held && remainingLifetime > 0) {
retained.push({
...held.session,
expiresIn: Math.ceil(remainingLifetime / 1000)
});
} else {
expired.push(stale[index]);
}
}
this.evict(expired, 'their token ran out and could not be renewed');
return [...usable, ...retained, ...renewed.filter(<T>(session?: T): session is T => Boolean(session))];
}
/**
@@ -531,8 +553,7 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
// The same session with a new token, so it keeps its id and is reported as changed rather
// than as one session going away and another arriving.
const next: vscode.AuthenticationSession = { ...session, accessToken: renewed.token };
this._transientSessions.set(next.id, { session: next, expiresAt: Date.now() + renewed.expiresIn * 1000 });
const next = this.storeTransientSession({ ...session, accessToken: renewed.token }, renewed.expiresIn);
this._logger.info(`Renewed session ${session.id}.`);
this._sessionChangeEmitter.fire({ added: [], removed: [], changed: [next] });
return next;
@@ -794,20 +815,19 @@ export class GitHubAuthenticationProvider implements vscode.AuthenticationProvid
const exchanged = await this._githubServer.loginWithMicrosoft(scopes, {
microsoftAccount: await this.rememberedMicrosoftAccount(gitHubAccountLabel)
});
const session = this.sessionFor(exchanged.account, exchanged.token, scopes);
const session = this.storeTransientSession(this.sessionFor(exchanged.account, exchanged.token, scopes), exchanged.expiresIn);
this.afterSessionLoad(session);
this._transientSessions.set(session.id, {
session,
// The exchange reports what GitHub said the token is good for; when that runs out is
// this side's question, since it is the side that has to hold the session until then.
expiresAt: Date.now() + exchanged.expiresIn * 1000
});
this._sessionChangeEmitter.fire({ added: [session], removed: [], changed: [] });
return session;
}
private storeTransientSession(session: vscode.AuthenticationSession, expiresIn: number): vscode.AuthenticationSession {
const result = { ...session, expiresIn };
this._transientSessions.set(result.id, { session: result, expiresAt: Date.now() + expiresIn * 1000 });
return result;
}
/**
* A session for a GitHub identity that has already been resolved, so nothing here needs a
* lookup.
@@ -8,7 +8,8 @@ import * as vscode from 'vscode';
import { AccountLinks } from '../common/accountLinks';
import { IGitHubUserInfo } from '../common/gitHubAccount';
import { Log } from '../common/logger';
import { EntraTokenExchangeError, EntraTokenExchangeFailure, IEntraRenewal, IEntraRenewedToken } from '../entraTokenExchange';
import { EntraTokenExchangeError, EntraTokenExchangeFailure, IEntraExchangedToken, IEntraLoginOptions, IEntraRenewal, IEntraRenewedToken } from '../entraTokenExchange';
import { GitHubSignInProvider } from '../flows';
import { AuthProviderType, GitHubAuthenticationProvider } from '../github';
import { TestMemento } from './testMemento';
@@ -128,6 +129,7 @@ suite('GitHub Microsoft-brokered sessions', () => {
_microsoftGeneration: number;
_microsoft: { getAccounts(): Promise<vscode.AuthenticationSessionAccountInformation[]> };
_githubServer: {
loginWithMicrosoft(scopes: readonly string[], options?: IEntraLoginOptions): Promise<IEntraExchangedToken>;
renewWithMicrosoft(renewal: IEntraRenewal): Promise<IEntraRenewedToken>;
sendAdditionalTelemetryInfo(session: vscode.AuthenticationSession): Promise<void>;
};
@@ -140,6 +142,8 @@ suite('GitHub Microsoft-brokered sessions', () => {
readonly accountLinks: AccountLinks;
/** Every renewal the provider put on the wire, in order. */
readonly renewals: IEntraRenewal[];
/** Every interactive Microsoft exchange the provider put on the wire, in order. */
readonly logins: Array<{ readonly scopes: readonly string[]; readonly options: IEntraLoginOptions | undefined }>;
/** What the provider told VS Code changed, as `verb account` for each session. */
readonly announced: string[];
/** The sessions still held in memory, as `account until` for each. */
@@ -163,8 +167,10 @@ suite('GitHub Microsoft-brokered sessions', () => {
/** Sessions already held in memory, and how long each has left in milliseconds. */
transient?: readonly (readonly [vscode.AuthenticationSession, number])[];
microsoftAccounts?: (call: number) => vscode.AuthenticationSessionAccountInformation[];
login?: (call: number, scopes: readonly string[], options: IEntraLoginOptions | undefined) => Promise<IEntraExchangedToken>;
renew?: (call: number, renewal: IEntraRenewal) => Promise<IEntraRenewedToken>;
} = {}): IHarness {
const logins: Array<{ scopes: readonly string[]; options: IEntraLoginOptions | undefined }> = [];
const renewals: IEntraRenewal[] = [];
const announced: string[] = [];
const accountLinks = new AccountLinks(new TestMemento(), STORAGE_KEY, logger);
@@ -186,6 +192,12 @@ suite('GitHub Microsoft-brokered sessions', () => {
getAccounts: async () => overrides.microsoftAccounts?.(microsoftReads++) ?? [MICROSOFT_ACCOUNT]
},
_githubServer: {
loginWithMicrosoft: async (scopes, options) => {
logins.push({ scopes, options });
return overrides.login
? await overrides.login(logins.length - 1, scopes, options)
: { token: `gho_login_${logins.length}`, expiresIn: 7200, account: GITHUB_ACCOUNT };
},
renewWithMicrosoft: async renewal => {
renewals.push(renewal);
return overrides.renew
@@ -210,6 +222,7 @@ suite('GitHub Microsoft-brokered sessions', () => {
provider: provider as GitHubAuthenticationProvider,
state: provider as IProviderState,
accountLinks,
logins,
renewals,
announced,
heldSessions: () => [...transientSessions.values()]
@@ -218,6 +231,24 @@ suite('GitHub Microsoft-brokered sessions', () => {
};
}
test('publishes the lifetime reported by an interactive Microsoft exchange', async () => {
const harness = createHarness();
const session = await harness.provider.createSession(SCOPES, { provider: GitHubSignInProvider.Microsoft });
assert.deepStrictEqual({
token: session.accessToken,
expiresIn: session.expiresIn,
logins: harness.logins,
announced: harness.announced,
}, {
token: 'gho_login_1',
expiresIn: 7200,
logins: [{ scopes: SCOPES, options: { microsoftAccount: undefined } }],
announced: ['added mona_contoso'],
});
});
async function withLink(harness: IHarness): Promise<IHarness> {
await harness.accountLinks.link(MICROSOFT_ACCOUNT.label, { id: GITHUB_ACCOUNT.id, label: GITHUB_ACCOUNT.accountName });
return harness;
@@ -235,16 +266,29 @@ suite('GitHub Microsoft-brokered sessions', () => {
scopes: sessions.map(session => session.scopes),
renewals: harness.renewals,
announced: harness.announced,
held: harness.heldSessions()
held: harness.heldSessions(),
expiresIn: sessions.map(session => session.expiresIn),
}, {
accounts: ['mona_contoso'],
scopes: [SCOPES],
renewals: [{ scopes: SCOPES, gitHubAccountId: '42', microsoftAccount: MICROSOFT_ACCOUNT }],
announced: ['added mona_contoso'],
held: ['mona_contoso live']
held: ['mona_contoso live'],
expiresIn: [3600]
});
});
test('recomputes the remaining lifetime of a cached Microsoft-brokered session', async () => {
const session = sessionFor('mona_contoso', 'cached', 'gho_cached');
const harness = createHarness({
transient: [[session, 2 * 60 * 60 * 1000]]
});
const [resolved] = await harness.provider.getSessions(SCOPES);
assert.ok(resolved.expiresIn !== undefined && resolved.expiresIn > 3600 && resolved.expiresIn <= 7200);
});
test('keeps what the user agreed to when a restore fails for a reason that says nothing about who they are', async () => {
const ambiguous = async (failure: EntraTokenExchangeFailure) => {
const harness = await withLink(createHarness({
@@ -315,29 +359,54 @@ suite('GitHub Microsoft-brokered sessions', () => {
});
});
test('settles every session whose token has run out, not only when there is nothing to hand back', async () => {
test('renews every session in the renewal window, not only when there is nothing else to hand back', async () => {
const harness = await withLink(createHarness({
// Another account, signed in the ordinary way, so it has no expiry and is always usable.
persisted: [sessionFor('hubot', 'persisted', 'gho_persisted')],
transient: [[sessionFor('mona_contoso', 'expired', 'gho_stale'), -1000]]
transient: [[sessionFor('mona_contoso', 'expiring', 'gho_stale'), 60 * 60 * 1000]]
}));
const sessions = await harness.provider.getSessions(SCOPES);
assert.deepStrictEqual({
accounts: sessions.map(session => session.account.label).sort(),
// An expired session left unprocessed is never handed out, never renewed and never
// A stale session left unprocessed is never handed out, never renewed and never
// reported as removed, but stays a candidate on every read for the life of the window.
held: harness.heldSessions(),
// Renewed in place, so it keeps its id and is reported as changed rather than as one
// account going away and another arriving.
announced: harness.announced,
tokens: sessions.map(session => session.accessToken).sort()
tokens: sessions.map(session => session.accessToken).sort(),
expirations: sessions.map(session => [session.account.label, session.expiresIn]).sort(),
}, {
accounts: ['hubot', 'mona_contoso'],
held: ['mona_contoso live'],
announced: ['changed mona_contoso'],
tokens: ['gho_1', 'gho_persisted']
tokens: ['gho_1', 'gho_persisted'],
expirations: [['hubot', undefined], ['mona_contoso', 3600]]
});
});
test('keeps a still-valid session when proactive renewal fails', async () => {
const harness = await withLink(createHarness({
transient: [[sessionFor('mona_contoso', 'expiring', 'gho_still_valid'), 60 * 60 * 1000]],
renew: async () => { throw new EntraTokenExchangeError(EntraTokenExchangeFailure.Network, 'offline'); }
}));
const sessions = await harness.provider.getSessions(SCOPES);
assert.deepStrictEqual({
tokens: sessions.map(session => session.accessToken),
hasUsableLifetime: sessions.every(session => session.expiresIn !== undefined && session.expiresIn > 0 && session.expiresIn <= 3600),
held: harness.heldSessions(),
announced: harness.announced,
renewals: harness.renewals.length
}, {
tokens: ['gho_still_valid'],
hasUsableLifetime: true,
held: ['mona_contoso live'],
announced: [],
renewals: 1
});
});
});
@@ -20,6 +20,7 @@
"../../src/vscode-dts/vscode.d.ts",
"../../src/vscode-dts/vscode.proposed.authIssuers.d.ts",
"../../src/vscode-dts/vscode.proposed.authProviderSpecific.d.ts",
"../../src/vscode-dts/vscode.proposed.authSessionAccountIcon.d.ts"
"../../src/vscode-dts/vscode.proposed.authSessionAccountIcon.d.ts",
"../../src/vscode-dts/vscode.proposed.authSessionExpiration.d.ts"
]
}
+25
View File
@@ -14,6 +14,31 @@ const week = day * 7;
const month = day * 30;
const year = day * 365;
/**
* Returns the absolute expiration time for a relative lifetime in seconds.
*/
export function getExpirationTime(expiresIn: number | undefined, now = Date.now()): number | undefined {
return expiresIn === undefined ? undefined : now + expiresIn * 1000;
}
/**
* Returns whether a known expiration time has passed.
*/
export function isExpired(expiresAt: number | undefined, now = Date.now()): boolean {
return expiresAt !== undefined && expiresAt <= now;
}
/**
* Returns the positive number of whole seconds until `expiresAt`, rounded up.
*/
export function getRemainingTimeInSeconds(expiresAt: number | undefined, now = Date.now()): number | undefined {
if (expiresAt === undefined) {
return undefined;
}
const remaining = Math.ceil((expiresAt - now) / 1000);
return remaining > 0 ? remaining : undefined;
}
/**
* Create a localized difference of the time between now and the specified date.
* @param date The date to generate the difference from.
+20 -1
View File
@@ -4,13 +4,32 @@
*--------------------------------------------------------------------------------------------*/
import { strictEqual } from 'assert';
import { fromNow, fromNowByDay, getDurationString, safeIntl } from '../../common/date.js';
import { fromNow, fromNowByDay, getDurationString, getExpirationTime, getRemainingTimeInSeconds, isExpired, safeIntl } from '../../common/date.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from './utils.js';
import { LANGUAGE_DEFAULT } from '../../common/platform.js';
suite('Date', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('getRemainingTimeInSeconds', () => {
strictEqual(getRemainingTimeInSeconds(undefined, 1000), undefined);
strictEqual(getRemainingTimeInSeconds(1000, 1000), undefined);
strictEqual(getRemainingTimeInSeconds(1001, 1000), 1);
strictEqual(getRemainingTimeInSeconds(2001, 1000), 2);
});
test('getExpirationTime', () => {
strictEqual(getExpirationTime(undefined, 1000), undefined);
strictEqual(getExpirationTime(0, 1000), 1000);
strictEqual(getExpirationTime(2, 1000), 3000);
});
test('isExpired', () => {
strictEqual(isExpired(undefined, 1000), false);
strictEqual(isExpired(1000, 1000), true);
strictEqual(isExpired(1001, 1000), false);
});
suite('fromNow', () => {
test('appendAgoLabel', () => {
strictEqual(fromNow(Date.now() - 35000), '35 secs');
@@ -37,6 +37,7 @@ import { AhpErrorCodes, JsonRpcErrorCodes } from '../common/state/protocol/error
import { ChatSourceKind, ContentEncoding, ResourceRequestParams, type CompletionsParams, type CompletionsResult, type CreateTerminalParams, type ResolveSessionConfigResult, type SessionConfigCompletionsResult } from '../common/state/protocol/commands.js';
import type { InvokeChangesetOperationParams, InvokeChangesetOperationResult } from '../common/state/protocol/channels-changeset/commands.js';
import { decodeBase64, encodeBase64 } from '../../../base/common/buffer.js';
import { getExpirationTime, getRemainingTimeInSeconds, isExpired } from '../../../base/common/date.js';
import type { FetchAutomationRunsParams, FetchAutomationRunsResult, ListAutomationTriggerDefinitionsParams, ListAutomationTriggerDefinitionsResult, RunAutomationParams, RunAutomationResult } from '../common/state/protocol/channels-automation/commands.js';
import { ILoadEstimator, LoadEstimator } from '../../../base/parts/ipc/common/ipc.net.js';
import { ITelemetryService, TelemetryLevel, TELEMETRY_CRASH_REPORTER_SETTING_ID, TELEMETRY_OLD_SETTING_ID, TELEMETRY_SETTING_ID } from '../../telemetry/common/telemetry.js';
@@ -275,7 +276,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
/** Pending JSON-RPC requests keyed by request id. */
private readonly _pendingRequests = new Map<number, IPendingRequest>();
private readonly _authentication = new Map<string, AuthenticateParams>();
private readonly _authentication = new Map<string, { readonly params: AuthenticateParams; readonly expiresAt: number | undefined }>();
private _nextRequestId = 1;
/**
@@ -1008,7 +1009,8 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
if (initialAuthentication) {
const normalizedParams = this._normalizeAuthenticationParams(initialAuthentication);
initialAuthenticationKey = this._authenticationKey(normalizedParams);
this._authentication.set(initialAuthenticationKey, normalizedParams);
const expiresAt = getExpirationTime(normalizedParams.expiresIn);
this._authentication.set(initialAuthenticationKey, { params: normalizedParams, expiresAt });
}
} catch (error) {
throw new InitialAuthenticationError(error);
@@ -1017,12 +1019,20 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
return;
}
}
await Promise.all([...this._authentication.entries()].map(async ([key, params]) => {
await Promise.all([...this._authentication.entries()].map(async ([key, authentication]) => {
const now = Date.now();
if (isExpired(authentication.expiresAt, now)) {
this._authentication.delete(key);
return;
}
const expiresIn = getRemainingTimeInSeconds(authentication.expiresAt, now);
const params = authentication.params;
try {
await this._dispatchRequest<CommandMap['authenticate']['result']>('authenticate', {
channel: ROOT_STATE_URI,
...params,
scopes: params.scopes ? [...params.scopes] : undefined,
...(expiresIn === undefined ? {} : { expiresIn }),
}, this._state.kind === AgentHostClientState.Connecting
? { bypassInitializeQueue: true, bypassReconnectGate: true }
: { bypassReconnectGate: true });
@@ -1411,6 +1421,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
*/
async authenticate(params: AuthenticateParams): Promise<AuthenticateResult> {
const normalizedParams = this._normalizeAuthenticationParams(params);
const expiresAt = getExpirationTime(params.expiresIn);
await this._sendRequest('authenticate', {
channel: ROOT_STATE_URI,
...normalizedParams,
@@ -1418,7 +1429,7 @@ export class AgentHostProtocolClient extends Disposable implements IAgentConnect
});
const key = this._authenticationKey(normalizedParams);
if (params.token) {
this._authentication.set(key, normalizedParams);
this._authentication.set(key, { params: normalizedParams, expiresAt });
} else {
this._authentication.delete(key);
}
+3 -1
View File
@@ -295,6 +295,8 @@ export interface AuthenticateParams {
/** The bearer token value (RFC 6750). */
readonly token: string;
/** The access token's remaining lifetime in seconds, when known. */
readonly expiresIn?: number;
}
/** Request for a previously accepted bearer token. */
@@ -1279,7 +1281,7 @@ export interface IAgent {
getProtectedResources(): ProtectedResourceMetadata[];
/** An empty token revokes the credential previously forwarded for this resource. */
authenticate(resource: string, token: string): Promise<boolean>;
authenticate(resource: string, token: string, expiresIn?: number): Promise<boolean>;
/** Optional token consumer for provider-owned resources such as MCP servers. */
handleAuthenticationToken?(params: AuthenticateParams): Promise<boolean>;
@@ -1 +1 @@
60706330
fd0471d4
@@ -1130,7 +1130,8 @@ export interface ResourceMkdirResult {
* ```jsonc
* // Client → Server
* { "jsonrpc": "2.0", "id": 3, "method": "authenticate",
* "params": { "channel": "ahp-root://", "resource": "https://api.github.com", "token": "gho_xxxx" } }
* "params": { "channel": "ahp-root://", "resource": "https://api.github.com",
* "token": "gho_xxxx", "expiresIn": 3540 } }
*
* // Server → Client (success)
* { "jsonrpc": "2.0", "id": 3, "result": {} }
@@ -1150,6 +1151,23 @@ export interface AuthenticateParams extends BaseParams {
resource: string;
/** Bearer token obtained from the resource's authorization server */
token: string;
/**
* The access token's remaining lifetime, in seconds, when this
* `authenticate` request is sent. This corresponds to `expires_in` in an
* OAuth 2.0 token response (RFC 6749 section 5.1).
*
* If the client retained the original token response, it MUST subtract the
* elapsed time before forwarding this value. Omit this field when the
* authorization server did not supply an expiry or the expiry is otherwise
* unknown. When supplied, the value MUST be a positive integer.
*
* This field is irrelevant when `token` is empty to revoke authentication
* and SHOULD be omitted in that case.
*
* @integer
* @minimum 1
*/
expiresIn?: number;
/**
* OAuth scopes the token grants, when known. Lets the server determine
* whether a specific challenge — e.g. the `requiredScopes` on a live
@@ -17,7 +17,10 @@ import type { ProtectedResourceMetadata, URI } from './state.js';
export const enum AuthRequiredReason {
/** The client has not yet authenticated for the resource */
Required = 'required',
/** A previously valid token has expired or been revoked */
/**
* A previously valid token has expired or been revoked. The client must
* acquire or renew the credential rather than replaying the challenged token.
*/
Expired = 'expired',
}
@@ -32,8 +35,9 @@ export const enum AuthRequiredReason {
* to; the `resource` field carries the complete OAuth protected resource
* metadata (per RFC 9728).
*
* Clients should obtain a fresh token and push it via the `authenticate`
* command.
* Clients should obtain or renew the credential and push the resulting token
* via the `authenticate` command. When `reason` is `expired`, clients MUST NOT
* blindly replay the challenged token.
*
* @category Protocol Notifications
* @method auth/required
@@ -3,6 +3,7 @@
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import { getExpirationTime, getRemainingTimeInSeconds, isExpired } from '../../../base/common/date.js';
import { Emitter, Event } from '../../../base/common/event.js';
import { Disposable } from '../../../base/common/lifecycle.js';
import { createDecorator } from '../../instantiation/common/instantiation.js';
@@ -34,6 +35,7 @@ interface IStoredAuthToken {
readonly resource: string;
readonly scopes: readonly string[];
readonly token: string;
readonly expiresAt: number | undefined;
}
export class AgentHostAuthenticationService extends Disposable implements IAgentHostAuthenticationService, IAgentHostAuthenticationController {
@@ -51,6 +53,7 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
async authenticate(params: AuthenticateParams, providers: Iterable<IAgent>): Promise<AuthenticateResult> {
this._logService.trace(`[AgentHostAuthenticationService] authenticate called: resource=${params.resource}`);
const expiresAt = getExpirationTime(params.expiresIn);
const providerList = [...providers];
// Multiple providers may share the same protected resource (e.g.
// both Copilot CLI and Claude consume the Copilot-scoped OAuth credential).
@@ -63,7 +66,7 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
p => p.getProtectedResources().some(r => r.resource === params.resource),
);
const settled = await Promise.allSettled(
matching.map(p => p.authenticate(params.resource, params.token)),
matching.map(p => p.authenticate(params.resource, params.token, params.expiresIn)),
);
let authenticated = false;
let rejected = false;
@@ -106,7 +109,7 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
// while clearing its own live state.
this._tokens.delete(key);
} else if (authenticated) {
this._tokens.set(key, { resource: params.resource, scopes, token: params.token });
this._tokens.set(key, { resource: params.resource, scopes, token: params.token, expiresAt });
}
const token = this._tokens.get(key)?.token;
if (previousToken !== token) {
@@ -117,11 +120,17 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
async replay(provider: IAgent): Promise<void> {
const protectedResources = new Set(provider.getProtectedResources().map(resource => resource.resource));
for (const stored of this._tokens.values()) {
const params: AuthenticateParams = { resource: stored.resource, scopes: stored.scopes, token: stored.token };
for (const [key, stored] of this._tokens) {
const now = Date.now();
if (isExpired(stored.expiresAt, now)) {
this._tokens.delete(key);
continue;
}
const expiresIn = getRemainingTimeInSeconds(stored.expiresAt, now);
const params: AuthenticateParams = { resource: stored.resource, scopes: stored.scopes, token: stored.token, expiresIn };
if (protectedResources.has(stored.resource)) {
try {
await provider.authenticate(stored.resource, stored.token);
await provider.authenticate(stored.resource, stored.token, expiresIn);
} catch (error) {
this._logService.error(error, `[AgentHostAuthenticationService] Provider '${provider.id}' rejected replayed authentication for resource=${stored.resource}`);
}
@@ -139,7 +148,7 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
getAuthToken(request: IAgentHostAuthTokenRequest): string | undefined {
const scopes = this._normalizeScopes(request.scopes);
const exact = this._tokens.get(this._key(request.resource, scopes));
if (exact) {
if (exact && !isExpired(exact.expiresAt)) {
return exact.token;
}
if (scopes.length === 0) {
@@ -149,7 +158,7 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
const requested = new Set(scopes);
let best: IStoredAuthToken | undefined;
for (const candidate of this._tokens.values()) {
if (candidate.resource !== request.resource || candidate.scopes.length === 0) {
if (candidate.resource !== request.resource || candidate.scopes.length === 0 || isExpired(candidate.expiresAt)) {
continue;
}
if (!this._containsAll(candidate.scopes, requested)) {
@@ -165,7 +174,8 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
// Compatibility for clients that resolved the right token before scopes
// were forwarded through the authenticate command.
return this._tokens.get(this._key(request.resource, []))?.token;
const unscoped = this._tokens.get(this._key(request.resource, []));
return unscoped && !isExpired(unscoped.expiresAt) ? unscoped.token : undefined;
}
private _containsAll(scopes: readonly string[], requested: ReadonlySet<string>): boolean {
@@ -184,4 +194,5 @@ export class AgentHostAuthenticationService extends Disposable implements IAgent
private _normalizeScopes(scopes: readonly string[] | undefined): readonly string[] {
return scopes ? [...new Set(scopes)].sort() : [];
}
}
@@ -83,6 +83,7 @@ import { createCopilotCliEnvironment } from './copilotCliEnvironment.js';
import { ICopilotSessionContext, projectFromCopilotContext } from './copilotGitProject.js';
import { parsedPluginsEqual, toChildCustomizations } from './copilotPluginConverters.js';
import { CopilotGitHubTelemetryForwarder } from './copilotGitHubTelemetryForwarder.js';
import { CopilotGitHubCredentials } from './copilotGitHubCredentials.js';
import { CopilotSecondaryAssignmentContext } from './copilotSecondaryAssignmentContext.js';
import { CopilotSessionLauncher, AutoTierConfigKey, ContextSizeConfigKey, ThinkingLevelConfigKey, getCopilotContextTier, isCopilotReasoningEffort, resolveCopilotAutoTier, resolveCopilotReasoningEffort, type CopilotSessionLaunchPlan, type IActiveClientSnapshot } from './copilotSessionLauncher.js';
import { CopilotAgentStartupConfig } from './copilotAgentStartupConfig.js';
@@ -842,7 +843,7 @@ export class CopilotAgent extends Disposable implements IAgent {
private _closedConnectionRecovery: { readonly clientFailureId: string; readonly promise: Promise<ICopilotClosedConnectionRecoveryResult> } | undefined;
private readonly _authenticationSequencer = new Sequencer();
private _updatingGitHubCredentials = false;
private _githubToken: string | undefined;
private readonly _githubCredentials = this._register(new CopilotGitHubCredentials());
private _serverToolHost: IAgentServerToolHost | undefined;
setServerToolHost(host: IAgentServerToolHost): void {
@@ -963,6 +964,7 @@ export class CopilotAgent extends Disposable implements IAgent {
@IAgentHostWorktreeIsolation worktree: IAgentHostWorktreeIsolation,
) {
super();
this._register(this._githubCredentials.onDidRequestRefresh(() => this._handleCopilotSessionAuthRequired()));
this._worktree = worktree;
this._lastStartupConfig = this._readClientStartupConfig();
this._plugins = this._register(this._instantiationService.createInstance(PluginController, () => this._ensureClient()));
@@ -1362,9 +1364,9 @@ export class CopilotAgent extends Disposable implements IAgent {
async getNetworkDiagnosticsEndpoints(): Promise<readonly IAgentHostNetworkEndpoint[]> {
let capiUrl = process.env['VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE'] || COPILOT_CAPI_URL;
if (this._githubToken) {
if (this._githubCredentials.token) {
try {
capiUrl = await this._copilotApiService.resolveApiEndpoint(this._githubToken) || capiUrl;
capiUrl = await this._copilotApiService.resolveApiEndpoint(this._githubCredentials.token) || capiUrl;
} catch (error) {
this._logService.debug(`[Copilot] CAPI endpoint discovery for network diagnostics failed; using ${capiUrl}: ${error instanceof Error ? error.message : String(error)}`);
}
@@ -1378,7 +1380,7 @@ export class CopilotAgent extends Disposable implements IAgent {
}
async getNetworkDiagnosticsAccount(): Promise<string | undefined> {
return this._githubToken ? this._copilotApiService.resolveUserLogin?.(this._githubToken) : undefined;
return this._githubCredentials.token ? this._copilotApiService.resolveUserLogin?.(this._githubCredentials.token) : undefined;
}
async getManagedSettingsDiagnostics(): Promise<IAgentHostManagedSettingsSnapshot> {
@@ -1403,7 +1405,7 @@ export class CopilotAgent extends Disposable implements IAgent {
stage = 'querying native MDM and GitHub managed settings';
return getCopilotManagedSettingsDiagnostics(
runtimeSdk,
this._githubToken,
this._githubCredentials.token,
this._gitHubEndpointService.getEnterpriseUri() ?? 'https://github.com',
AbortSignal.timeout(COPILOT_MANAGED_SETTINGS_DIAGNOSTICS_TIMEOUT_MS),
COPILOT_MANAGED_SETTINGS_QUERY_TIMEOUT_MS,
@@ -1718,7 +1720,7 @@ export class CopilotAgent extends Disposable implements IAgent {
};
}
async authenticate(resource: string, token: string): Promise<boolean> {
async authenticate(resource: string, token: string, expiresIn?: number): Promise<boolean> {
if (resource === this._gitHubEndpointService.getRepoResource().resource) {
return true;
}
@@ -1733,18 +1735,18 @@ export class CopilotAgent extends Disposable implements IAgent {
if (token) {
this._authenticationRequired.set(undefined, undefined);
}
await this._applyGitHubToken(token || undefined);
await this._applyGitHubToken(token || undefined, expiresIn);
});
return true;
}
private async _applyGitHubToken(token: string | undefined): Promise<void> {
if (this._githubToken === token) {
private async _applyGitHubToken(token: string | undefined, expiresIn: number | undefined): Promise<void> {
const { tokenChanged, modeChanged: tokenProviderModeChanged } = this._githubCredentials.update(token, expiresIn);
if (!tokenChanged && !tokenProviderModeChanged) {
return;
}
this._logService.info(`[Copilot] Auth token ${token ? 'updated' : 'cleared'}`);
this._telemetryService.setCommonProperty('copilotSku', undefined);
this._githubToken = token;
this._updateRestrictedTelemetry(token);
this._refreshProxy();
if (!token) {
@@ -1765,21 +1767,27 @@ export class CopilotAgent extends Disposable implements IAgent {
return;
}
const host = this._gitHubEndpointService.getEnterpriseUri() ?? 'https://github.com';
let restartRequired = false;
let restartRequired = tokenProviderModeChanged;
this._updatingGitHubCredentials = true;
try {
for (const session of this._allLiveSessions()) {
try {
const result = await session.updateGitHubCredentials(host, token);
if (!result.success) {
if (!tokenProviderModeChanged) {
for (const session of this._allLiveSessions()) {
// Provider-backed SDK sessions receive this token through their registered callback.
if (!session.usesStaticGitHubToken) {
continue;
}
try {
const result = await session.updateGitHubCredentials(host, token);
if (!result.success) {
restartRequired = true;
this._logService.warn(`[Copilot:${session.sessionId}] GitHub credential update was rejected; scheduling a safe CopilotClient restart`);
} else if (result.copilotUserResolved === false) {
this._logService.warn(`[Copilot:${session.sessionId}] GitHub credentials were updated, but Copilot user metadata could not be resolved; plan, quota, and billing metadata may be degraded. Reauthenticate to restore it.`);
}
} catch (error) {
restartRequired = true;
this._logService.warn(`[Copilot:${session.sessionId}] GitHub credential update was rejected; scheduling a safe CopilotClient restart`);
} else if (result.copilotUserResolved === false) {
this._logService.warn(`[Copilot:${session.sessionId}] GitHub credentials were updated, but Copilot user metadata could not be resolved; plan, quota, and billing metadata may be degraded. Reauthenticate to restore it.`);
this._logService.warn(`[Copilot:${session.sessionId}] Failed to update GitHub credentials; scheduling a safe CopilotClient restart: ${getErrorMessage(error)}`);
}
} catch (error) {
restartRequired = true;
this._logService.warn(`[Copilot:${session.sessionId}] Failed to update GitHub credentials; scheduling a safe CopilotClient restart: ${getErrorMessage(error)}`);
}
}
} finally {
@@ -1787,7 +1795,7 @@ export class CopilotAgent extends Disposable implements IAgent {
await this._applyPendingClientRestart();
}
if (restartRequired) {
await this._requestClientRestart('GitHub credential update failed');
await this._requestClientRestart(tokenProviderModeChanged ? 'GitHub credential mode changed' : 'GitHub credential update failed');
}
await this._resolveCopilotSku(token);
void this._scheduleModelRefresh();
@@ -1803,7 +1811,7 @@ export class CopilotAgent extends Disposable implements IAgent {
private async _resolveCopilotSku(githubToken: string): Promise<void> {
try {
const copilotSku = await this._copilotApiService.resolveCopilotSku?.(githubToken);
if (copilotSku && this._githubToken === githubToken) {
if (copilotSku && this._githubCredentials.token === githubToken) {
this._telemetryService.setCommonProperty('copilotSku', copilotSku);
}
} catch (err) {
@@ -1832,7 +1840,7 @@ export class CopilotAgent extends Disposable implements IAgent {
private async _resolveRestrictedTelemetry(githubToken: string): Promise<void> {
try {
const ctx = await this._copilotApiService.resolveRestrictedTelemetryContext(githubToken);
if (this._githubToken !== githubToken) {
if (this._githubCredentials.token !== githubToken) {
return; // token changed while resolving; a newer call owns the state
}
this._applyRestrictedTelemetry({
@@ -1882,7 +1890,7 @@ export class CopilotAgent extends Disposable implements IAgent {
}
const sessionId = notification.sessionId;
const githubToken = this._githubToken;
const githubToken = this._githubCredentials.token;
if (!githubToken) {
await router.route(notification, undefined, additionalProperties);
return;
@@ -1890,7 +1898,7 @@ export class CopilotAgent extends Disposable implements IAgent {
try {
const context = await this._copilotApiService.resolveRestrictedTelemetryContext(githubToken);
if (this._githubToken !== githubToken) {
if (this._githubCredentials.token !== githubToken) {
return;
}
await router.route(notification, {
@@ -2014,7 +2022,7 @@ export class CopilotAgent extends Disposable implements IAgent {
return;
}
const tokenAtRefreshStart = this._githubToken;
const tokenAtRefreshStart = this._githubCredentials.token;
if (!tokenAtRefreshStart) {
this._capiModels = [];
this._publishModels();
@@ -2022,7 +2030,7 @@ export class CopilotAgent extends Disposable implements IAgent {
}
try {
const models = await this._listModels(tokenAtRefreshStart);
if (this._githubToken === tokenAtRefreshStart && this._modelCatalogGeneration === generation) {
if (this._githubCredentials.token === tokenAtRefreshStart && this._modelCatalogGeneration === generation) {
this._capiModels = models;
this._publishModels();
}
@@ -2030,7 +2038,7 @@ export class CopilotAgent extends Disposable implements IAgent {
// Token rotated mid-flight — a newer refresh owns the result — or
// teardown began while the request was in flight, in which case a
// retry would just resurrect the client we are tearing down.
if (this._githubToken !== tokenAtRefreshStart || this._modelCatalogGeneration !== generation || this._shutdownPromise) {
if (this._githubCredentials.token !== tokenAtRefreshStart || this._modelCatalogGeneration !== generation || this._shutdownPromise) {
return;
}
if (/\b401\b/.test(getErrorMessage(err))) {
@@ -3958,7 +3966,7 @@ export class CopilotAgent extends Disposable implements IAgent {
disabledRootMcpServers: await this._disabledRootMcpServers(sessionUri, sdkSessionId, snapshot),
activeClientToolSet: activeClient.toolSet,
shellManager,
githubToken: this._githubToken,
githubCredentials: this._githubCredentials.forSession(),
model: provisional.model,
longContextWindow: this._longContextWindowFor(provisional.model?.id),
freeLongContext: this._isFreeLongContext(provisional.model?.id),
@@ -4480,7 +4488,7 @@ export class CopilotAgent extends Disposable implements IAgent {
disabledRootMcpServers: await this._disabledRootMcpServers(session, sdkSessionId, snapshot),
activeClientToolSet: activeClient.toolSet,
shellManager,
githubToken: this._githubToken,
githubCredentials: this._githubCredentials.forSession(),
fallback: { model, longContextWindow: this._longContextWindowFor(model?.id), freeLongContext: this._isFreeLongContext(model?.id) },
};
} else {
@@ -4495,7 +4503,7 @@ export class CopilotAgent extends Disposable implements IAgent {
disabledRootMcpServers: await this._disabledRootMcpServers(session, chatSdkId, snapshot),
activeClientToolSet: activeClient.toolSet,
shellManager,
githubToken: this._githubToken,
githubCredentials: this._githubCredentials.forSession(),
model,
longContextWindow: this._longContextWindowFor(model?.id),
freeLongContext: this._isFreeLongContext(model?.id),
@@ -4939,7 +4947,7 @@ export class CopilotAgent extends Disposable implements IAgent {
disabledRootMcpServers: await this._disabledRootMcpServers(configurationResource, info.sdkSessionId, snapshot),
activeClientToolSet: activeClient.toolSet,
shellManager,
githubToken: this._githubToken,
githubCredentials: this._githubCredentials.forSession(),
fallback: { model: info.model, longContextWindow: this._longContextWindowFor(info.model?.id), freeLongContext: this._isFreeLongContext(info.model?.id) },
};
agentSession = this._createAgentSession(launchPlan, workingDirectory, activeClient, { sessionUri: configurationResource, chatChannelUri: chat, resource: context.resource });
@@ -5099,6 +5107,7 @@ export class CopilotAgent extends Disposable implements IAgent {
if (!this._shutdownPromise) {
this._isShuttingDown = true;
this._sessionsPendingRegistration.clearAndDisposeAll();
this._githubCredentials.shutdown();
for (const lifetime of this._sessionLifetimes.values()) {
void lifetime.close();
}
@@ -5213,9 +5222,9 @@ export class CopilotAgent extends Disposable implements IAgent {
}
let capiUrl = env['VSCODE_AGENT_HOST_CAPI_URL_OVERRIDE'] || COPILOT_CAPI_URL;
if (this._githubToken) {
if (this._githubCredentials.token) {
try {
const discovered = await this._copilotApiService.resolveApiEndpoint(this._githubToken);
const discovered = await this._copilotApiService.resolveApiEndpoint(this._githubCredentials.token);
if (discovered) {
capiUrl = discovered;
}
@@ -5316,7 +5325,6 @@ export class CopilotAgent extends Disposable implements IAgent {
// MCP reconcile has no host call of its own, so read the retained host snapshot lazily.
hostCustomizations: () => this._retainedHostCustomizations(sessionUri),
serverToolHost: this._serverToolHost,
isLaunchTokenCurrent: () => this._githubToken === launchPlan.githubToken,
onTurnEnded: () => this._onChatTurnEnded(),
},
);
@@ -5496,7 +5504,7 @@ export class CopilotAgent extends Disposable implements IAgent {
disabledRootMcpServers: await this._disabledRootMcpServers(sessionUri, sessionId, snapshot),
activeClientToolSet: activeClient.toolSet,
shellManager,
githubToken: this._githubToken,
githubCredentials: this._githubCredentials.forSession(),
workspaceless: storedMetadata.workspaceless,
fallback: {
model: storedMetadata.model,
@@ -481,8 +481,6 @@ export interface ICopilotAgentSessionOptions {
* the future) and exposes SDK tool handlers that execute them in-process.
*/
readonly serverToolHost?: IAgentServerToolHost;
/** Returns whether the token that launched this session is still the active account token. */
readonly isLaunchTokenCurrent?: () => boolean;
/** Overrides source-launch detection for deterministic tests. */
readonly enableDevelopmentErrorInjection?: boolean;
@@ -932,6 +930,7 @@ export class CopilotAgentSession extends Disposable {
* non-destructive idle release to avoid disconnecting mid-turn.
*/
get hasActiveTurn(): boolean { return this._currentTurn.value !== undefined; }
get usesStaticGitHubToken(): boolean { return this._launchPlan.githubCredentials.usesStaticToken; }
get chatUri(): URI { return this._chatChannelUri; }
get currentTurnId(): string | undefined { return this._currentTurn.value?.id; }
@@ -1094,7 +1093,6 @@ export class CopilotAgentSession extends Disposable {
private readonly _shellInitScriptInstanceId = generateUuid().substring(0, 8);
private readonly _launchPlan: CopilotSessionLaunchPlan;
private _detectInterruptedTurnOnRestore: boolean;
private readonly _isLaunchTokenStillCurrent: () => boolean;
/** Notifies the agent that this chat's turn ended. See {@link ICopilotAgentSessionOptions.onTurnEnded}. */
private readonly _onTurnEnded: () => void;
private readonly _shellManager: ShellManager | undefined;
@@ -1150,6 +1148,7 @@ export class CopilotAgentSession extends Disposable {
private readonly _repoInfoTelemetry: AgentHostRepoInfoTelemetry;
private _activeRepoInfoTurn: {
readonly telemetryMessageId: string;
readonly githubToken: string | undefined;
cancelled: boolean;
begin: Promise<{ readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined>;
} | undefined;
@@ -1182,7 +1181,6 @@ export class CopilotAgentSession extends Disposable {
this._sessionLauncher = options.sessionLauncher;
this._launchPlan = options.launchPlan;
this._detectInterruptedTurnOnRestore = options.launchPlan.kind === 'resume';
this._isLaunchTokenStillCurrent = options.isLaunchTokenCurrent ?? (() => true);
this._onTurnEnded = options.onTurnEnded ?? (() => { });
this._shellManager = options.shellManager;
this._nonPtyShellTerminals = this._register(this._instantiationService.createInstance(NonPtyShellTerminalStreams, options.sessionUri, options.chatChannelUri));
@@ -2260,9 +2258,13 @@ export class CopilotAgentSession extends Disposable {
/** Updates the GitHub credentials used by this live SDK session. */
async updateGitHubCredentials(host: string, token: string): Promise<GitHubCredentialsUpdateResult> {
return this._wrapper.session.rpc.gitHubAuth.setCredentials({
const result = await this._wrapper.session.rpc.gitHubAuth.setCredentials({
credentials: { type: 'token', host, token },
});
if (result.success) {
this._launchPlan.githubCredentials.updateStaticToken(token);
}
return result;
}
private _setPromptCacheState(promptCache: ISessionPromptCacheState | undefined): void {
@@ -2383,7 +2385,7 @@ export class CopilotAgentSession extends Disposable {
}
private async _initialGitHubMcpToken(request: McpAuthRequest): Promise<string | undefined> {
const githubToken = this._launchPlan.githubToken;
const githubToken = this._currentGitHubToken;
const requestUrl = normalizeMcpServerUrl(request.serverUrl);
if (!githubToken || requestUrl === undefined) {
return undefined;
@@ -4705,10 +4707,10 @@ export class CopilotAgentSession extends Disposable {
}
}
private async _beginRepoInfoTelemetry(telemetryMessageId: string, clientType: AgentHostClientType, isCurrent: () => boolean): Promise<{ readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined> {
private async _beginRepoInfoTelemetry(telemetryMessageId: string, clientType: AgentHostClientType, githubToken: string | undefined, isCurrent: () => boolean): Promise<{ readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined> {
let resolved: { readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined;
try {
resolved = await this._resolveRepoInfoTelemetryContext();
resolved = await this._resolveRepoInfoTelemetryContext(githubToken);
} catch (error) {
this._logService.warn(`[Copilot:${this.sessionId}] Failed to resolve repository info telemetry context: ${getErrorMessage(error)}`);
return undefined;
@@ -4733,10 +4735,18 @@ export class CopilotAgentSession extends Disposable {
return;
}
this._activeRepoInfoTurn = undefined;
const isCurrent = () => !turn.cancelled && this._isLaunchTokenCurrent();
const isCurrent = () => !turn.cancelled && turn.githubToken !== undefined && this._isGitHubTokenCurrent(turn.githubToken);
void turn.begin.then(resolved => this._endRepoInfoTelemetry(turn.telemetryMessageId, resolved, isCurrent));
}
private _isGitHubTokenCurrent(token: string): boolean {
return this._launchPlan.githubCredentials.isCurrentToken(token);
}
private get _currentGitHubToken(): string | undefined {
return this._launchPlan.githubCredentials.token;
}
private _cancelActiveRepoInfoTelemetry(): void {
const turn = this._activeRepoInfoTurn;
if (!turn) {
@@ -4747,11 +4757,10 @@ export class CopilotAgentSession extends Disposable {
void turn.begin.finally(() => this._repoInfoTelemetry.clearTurn(turn.telemetryMessageId));
}
private async _resolveRepoInfoTelemetryContext(): Promise<{ readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined> {
private async _resolveRepoInfoTelemetryContext(githubToken: string | undefined): Promise<{ readonly context: IAgentHostRestrictedTelemetryContext; readonly baseBranch: string | undefined } | undefined> {
if (this._configurationService.getRootValue(platformRootSchema, AgentHostDisableRepoInfoTelemetryConfigKey) === true) {
return undefined;
}
const githubToken = this._launchPlan.githubToken;
if (!githubToken) {
return undefined;
}
@@ -4765,10 +4774,6 @@ export class CopilotAgentSession extends Disposable {
return { context: this._toRepoInfoTelemetryContext(rawContext), baseBranch };
}
private _isLaunchTokenCurrent(): boolean {
return this._launchPlan.githubToken !== undefined && this._isLaunchTokenStillCurrent();
}
private _toRepoInfoTelemetryContext(context: IRestrictedTelemetryContext): IAgentHostRestrictedTelemetryContext {
return {
restrictedTelemetryEnabled: context.restrictedTelemetryEnabled,
@@ -6535,11 +6540,12 @@ export class CopilotAgentSession extends Disposable {
this._cancelActiveRepoInfoTelemetry();
const turn: NonNullable<CopilotAgentSession['_activeRepoInfoTurn']> = {
telemetryMessageId,
githubToken: this._currentGitHubToken,
cancelled: false,
begin: Promise.resolve(undefined),
};
const isCurrent = () => !turn.cancelled && this._isLaunchTokenCurrent();
turn.begin = this._beginRepoInfoTelemetry(telemetryMessageId, this._currentTurn.value?.clientType ?? AgentHostClientType.Unknown, isCurrent);
const isCurrent = () => !turn.cancelled && turn.githubToken !== undefined && this._isGitHubTokenCurrent(turn.githubToken);
turn.begin = this._beginRepoInfoTelemetry(telemetryMessageId, this._currentTurn.value?.clientType ?? AgentHostClientType.Unknown, turn.githubToken, isCurrent);
this._activeRepoInfoTurn = turn;
}
}));
@@ -0,0 +1,145 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import type { GitHubTokenProvider } from '@github/copilot-sdk';
import { DeferredPromise, timeout } from '../../../../base/common/async.js';
import { getExpirationTime, getRemainingTimeInSeconds } from '../../../../base/common/date.js';
import { Emitter, Event } from '../../../../base/common/event.js';
import { Disposable } from '../../../../base/common/lifecycle.js';
const COPILOT_GITHUB_TOKEN_REFRESH_THRESHOLD_SECONDS = 60 * 60;
const COPILOT_GITHUB_TOKEN_REFRESH_TIMEOUT_MS = 30_000;
type CopilotGitHubSdkSessionOptions =
| { readonly gitHubToken: string | undefined; readonly gitHubTokenProvider?: never }
| { readonly gitHubToken?: never; readonly gitHubTokenProvider: GitHubTokenProvider };
/**
* The GitHub credential configuration captured when an SDK session launches.
*/
export class CopilotGitHubSessionCredentials {
private constructor(
private _staticToken: string | undefined,
private readonly _provider: CopilotGitHubCredentials | undefined,
) { }
static fromToken(token: string | undefined): CopilotGitHubSessionCredentials {
return new CopilotGitHubSessionCredentials(token, undefined);
}
static fromProvider(provider: CopilotGitHubCredentials): CopilotGitHubSessionCredentials {
return new CopilotGitHubSessionCredentials(undefined, provider);
}
get usesStaticToken(): boolean {
return this._provider === undefined;
}
get token(): string | undefined {
return this._provider?.token ?? this._staticToken;
}
get sdkSessionOptions(): CopilotGitHubSdkSessionOptions {
return this._provider
? { gitHubTokenProvider: this._provider.tokenProvider }
: { gitHubToken: this._staticToken };
}
isCurrentToken(token: string): boolean {
return this.token === token;
}
updateStaticToken(token: string): void {
if (!this.usesStaticToken) {
throw new Error('Cannot update provider-backed GitHub credentials as a static token');
}
this._staticToken = token;
}
}
/**
* Owns the current GitHub credential mode and supplies refreshable credentials to SDK sessions.
*/
export class CopilotGitHubCredentials extends Disposable {
private _token: string | undefined;
private _expiresAt: number | undefined;
private _usesTokenProvider = false;
private _pendingRefresh: DeferredPromise<void> | undefined;
private _isShutdown = false;
constructor(
private readonly _now = Date.now,
private readonly _refreshTimeoutMs = COPILOT_GITHUB_TOKEN_REFRESH_TIMEOUT_MS,
) {
super();
}
private readonly _onDidRequestRefresh = this._register(new Emitter<void>());
readonly onDidRequestRefresh: Event<void> = this._onDidRequestRefresh.event;
get token(): string | undefined {
return this._token;
}
readonly tokenProvider: GitHubTokenProvider = async ({ reason }) => {
let expiresIn = getRemainingTimeInSeconds(this._expiresAt, this._now());
if (!this._isShutdown && this._usesTokenProvider && (reason === 'refresh' || expiresIn === undefined || expiresIn <= COPILOT_GITHUB_TOKEN_REFRESH_THRESHOLD_SECONDS)) {
await this._requestRefresh();
expiresIn = getRemainingTimeInSeconds(this._expiresAt, this._now());
}
if (this._isShutdown || !this._usesTokenProvider || !this._token || expiresIn === undefined || expiresIn <= COPILOT_GITHUB_TOKEN_REFRESH_THRESHOLD_SECONDS) {
return { kind: 'cancelled' };
}
return { kind: 'token', accessToken: this._token, expiresIn };
};
forSession(): CopilotGitHubSessionCredentials {
return this._usesTokenProvider
? CopilotGitHubSessionCredentials.fromProvider(this)
: CopilotGitHubSessionCredentials.fromToken(this._token);
}
update(token: string | undefined, expiresIn: number | undefined): { readonly tokenChanged: boolean; readonly modeChanged: boolean } {
const tokenChanged = this._token !== token;
const usesTokenProvider = token !== undefined && expiresIn !== undefined;
const modeChanged = this._usesTokenProvider !== usesTokenProvider;
this._token = token;
this._expiresAt = usesTokenProvider ? getExpirationTime(expiresIn, this._now()) : undefined;
this._usesTokenProvider = usesTokenProvider;
this._completePendingRefresh();
return { tokenChanged, modeChanged };
}
shutdown(): void {
this._isShutdown = true;
this.update(undefined, undefined);
}
private async _requestRefresh(): Promise<void> {
let pending = this._pendingRefresh;
if (!pending) {
pending = new DeferredPromise<void>();
this._pendingRefresh = pending;
this._onDidRequestRefresh.fire();
}
const refreshTimeout = timeout(this._refreshTimeoutMs);
try {
await Promise.race([pending.p, refreshTimeout]);
} finally {
refreshTimeout.cancel();
}
}
private _completePendingRefresh(): void {
const pending = this._pendingRefresh;
this._pendingRefresh = undefined;
pending?.complete();
}
override dispose(): void {
this.shutdown();
super.dispose();
}
}
@@ -30,6 +30,7 @@ import { IAgentHostSessionOpenTelemetry } from '../agentHostSessionOpenTelemetry
import { IByokLmBridgeRegistry } from '../byokLmBridgeRegistry.js';
import { IByokLmProxyService, type IByokLmProxyHandle } from './byokLmProxyService.js';
import type { ICopilotMcpServerInfo, ICopilotPluginInfo } from './copilotAgent.js';
import { CopilotGitHubSessionCredentials } from './copilotGitHubCredentials.js';
import { toSdkHooks, toSdkInstructionDirectories, toSdkMcpServers, toSdkMcpServersFromConfigMap, toSdkSessionCustomAgents, toSdkSkillDirectories } from './copilotPluginConverters.js';
import { CopilotSessionWrapper } from './copilotSessionWrapper.js';
import { ShellManager, createShellTools, type IUnsandboxedCommandConfirmationRequest } from './copilotShellTools.js';
@@ -252,7 +253,7 @@ interface ICopilotSessionLaunchBase {
*/
readonly activeClientToolSet: ActiveClientToolSet;
readonly shellManager: ShellManager | undefined;
readonly githubToken: string | undefined;
readonly githubCredentials: CopilotGitHubSessionCredentials;
/**
* Whether this is a workspace-less session. Threaded into the
@@ -1024,14 +1025,7 @@ export class CopilotSessionLauncher implements ICopilotSessionLauncher {
pluginDirectories: coalesce(plugins.map(p => p.pluginDir))
.filter(d => d.scheme === Schemas.file).map(d => d.fsPath),
tools: promptOverrides.tools,
// Pass the GitHub token at the session level. The SDK's
// client-level `gitHubToken` authenticates the CLI process,
// but each session also needs its own token resolved into a
// GitHub identity (login, Copilot plan, endpoints) to drive
// model routing and quota — without this the session
// errors with "Session was not created with authentication
// info or custom provider" on first send. See #318693.
gitHubToken: plan.githubToken,
...plan.githubCredentials.sdkSessionOptions,
// Enable infinite sessions so the SDK provisions a workspace
// directory (containing `plan.md`, `checkpoints/`, `files/`).
// The workspace is required for plan mode to work — without
@@ -2915,7 +2915,7 @@ suite('AgentHostProtocolClient', () => {
jsonrpc: '2.0', id: initialAnnotationsSubscribe.id,
result: { snapshot: { resource: annotationsUri.toString(), state: { annotations: [] }, fromSeq: 5 } },
});
const authentication = client.authenticate({ resource: 'https://api.github.com', token: 'token' });
const authentication = client.authenticate({ resource: 'https://api.github.com', token: 'token', expiresIn: 3600 });
const initialAuthenticate = await waitForRequest(transports[0], 'authenticate');
transports[0].fireMessage({ jsonrpc: '2.0', id: initialAuthenticate.id, result: {} });
await authentication;
@@ -2961,6 +2961,8 @@ suite('AgentHostProtocolClient', () => {
});
const restoredAuthenticate = await waitForRequestAt(reconnectTransport, 'authenticate', 0);
const restoredExpiresIn = (restoredAuthenticate.params as { expiresIn?: number }).expiresIn;
assert.ok(restoredExpiresIn !== undefined && restoredExpiresIn > 0 && restoredExpiresIn <= 3600);
const managedSettings = reconnectTransport.sentMessages.find(message => hasKey(message, { method: true }) && message.method === 'setClientManagedSettingsPermissions');
assert.ok(managedSettings, 'managed settings should be restored after fresh initialization');
assert.ok(
@@ -7178,10 +7178,10 @@ suite('AgentService (node dispatcher)', () => {
test('routes token to provider matching the resource', async () => {
registerTestAgentProvider(service, copilotAgent);
const result = await service.authenticate({ resource: 'https://api.github.com', token: 'ghp_test123' });
const result = await service.authenticate({ resource: 'https://api.github.com', token: 'ghp_test123', expiresIn: 3600 });
assert.deepStrictEqual(result, { authenticated: true });
assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'ghp_test123' }]);
assert.deepStrictEqual(copilotAgent.authenticateCalls, [{ resource: 'https://api.github.com', token: 'ghp_test123', expiresIn: 3600 }]);
});
test('returns not authenticated for unknown resource', async () => {
@@ -49,7 +49,7 @@ import { AgentHostClientConnectionKind, AgentHostLaunchKind, AgentHostTransportK
import { ISessionDataService } from '../../common/sessionDataService.js';
import { buildDefaultChatUri, buildChatUri, buildSubagentChatUri, buildSubagentSessionUri, parseRequiredSessionUriFromChatUri, CustomizationLoadStatus, MessageKind, readSessionEhcliAdoptable, ResponsePartKind, ROOT_STATE_URI, ToolResultContentType, TurnState, customizationId, AH_META_IS_ARCHIVED_DB_KEY, AH_META_IS_READ_DB_KEY, type ClientPluginCustomization, type Customization, type PluginCustomization, type ToolCallResult, type Turn, RuleCustomization } from '../../common/state/sessionState.js';
import { ChatOriginKind, CustomizationEnablementKind, CustomizationType, SessionStatus, ToolCallContributorKind, type AgentSelection, type ModelSelection, type ProtectedResourceMetadata, type ToolDefinition } from '../../common/state/protocol/state.js';
import { ActionType, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js';
import { ActionType, AuthRequiredReason, type ChatAction, type SessionAction } from '../../common/state/sessionActions.js';
import { AgentConfigurationService, IAgentConfigurationService } from '../../node/agentConfigurationService.js';
import { AgentHostAuthenticationService, IAgentHostAuthenticationService } from '../../node/agentHostAuthenticationService.js';
@@ -63,6 +63,7 @@ import { IAgentHostTerminalManager } from '../../node/agentHostTerminalManager.j
import { IAgentHostOTelService } from '../../common/otel/agentHostOTelService.js';
import { AgentHostCompletions, IAgentHostCompletions } from '../../node/agentHostCompletions.js';
import { COPILOT_AGENT_HOST_SYSTEM_MESSAGE, CopilotAgent, getCopilotManagedSettingsDiagnostics, rebaseUnder, REFRESH_DEBOUNCE_MS, resolveCopilotOtlpMetricsEndpoint } from '../../node/copilot/copilotAgent.js';
import { CopilotGitHubSessionCredentials } from '../../node/copilot/copilotGitHubCredentials.js';
import { GITHUB_MCP_SERVER_NAME } from '../../node/shared/githubMcpServer.js';
import { AGENT_HOST_FILE_LINK_INSTRUCTIONS } from '../../node/shared/fileLinkInstructions.js';
import { COPILOT_AGENT_HOST_LARGE_OUTPUT_TOOL_INSTRUCTION } from '../../node/copilot/prompts/toolInstructions.js';
@@ -200,11 +201,13 @@ function setDefaultSessionStub(agent: CopilotAgent, sessionId: string, stub: unk
sessionUri?: URI;
resourceUri?: URI;
chatChannelUri?: URI;
usesStaticGitHubToken?: boolean;
bindChatChannel?: (uri: URI) => void;
destroySession?: () => Promise<void>;
};
typed.sessionId ??= sessionId;
typed.sessionUri ??= sessionUri;
typed.usesStaticGitHubToken ??= true;
// A session-backed (default) chat's host-chosen persistence scope is the
// session itself; that is how the agent identifies it without rebuilding a
// default-chat URI (see `CopilotAgent._findSessionChat`).
@@ -767,6 +770,7 @@ interface IFakeAgentSession {
interface ICredentialUpdateSession {
readonly hasActiveTurn: boolean;
readonly usesStaticGitHubToken?: boolean;
updateGitHubCredentials(host: string, token: string): Promise<{ readonly success: boolean; readonly copilotUserResolved?: boolean }>;
dispose(): void;
}
@@ -777,6 +781,7 @@ class MockCopilotSession {
readonly workingDirectoryCalls: string[] = [];
readonly workingDirectoryErrors: Array<Error | undefined> = [];
readonly workingDirectoryResults: string[] = [];
readonly gitHubCredentialUpdates: Array<{ credentials: { type: 'token'; host: string; token: string } }> = [];
readonly rpc = {
eventLog: {
registerInterest: async () => ({ handle: 'sampling-interest' }),
@@ -786,7 +791,10 @@ class MockCopilotSession {
update: async () => ({ success: true }),
},
gitHubAuth: {
setCredentials: async () => ({ success: true, copilotUserResolved: true }),
setCredentials: async (params: { credentials: { type: 'token'; host: string; token: string } }) => {
this.gitHubCredentialUpdates.push(params);
return { success: true, copilotUserResolved: true };
},
},
permissions: {
setMode: async ({ mode }: { mode: PermissionMode }) => ({ success: true, mode }),
@@ -1196,7 +1204,7 @@ function createAgentSessionThroughAgent(agent: CopilotAgent, instantiationServic
resolvedAgentName: undefined,
snapshot: options?.snapshot ?? { tools: [], plugins: [], mcpServers: {} },
shellManager,
githubToken: 'token',
githubCredentials: CopilotGitHubSessionCredentials.fromToken('token'),
model: undefined,
};
return { session: agentInternals._createAgentSession(launchPlan, options?.workingDirectory, activeClient), activeClient, createOptions: () => createOptions };
@@ -2618,7 +2626,7 @@ suite('CopilotAgent', () => {
await waitForState(agent.models, models => models.length === 0);
assert.deepStrictEqual({
githubToken: agent['_githubToken'],
githubToken: agent['_githubCredentials'].token,
models: agent.models.get(),
}, {
githubToken: undefined,
@@ -2658,7 +2666,7 @@ suite('CopilotAgent', () => {
}
});
test('updates every live session after a changed auth token without restarting an unchanged proxy', async () => {
test('updates live sessions and restarts only when the credential mode changes', async () => {
const client = new TestCopilotClient([], [{
id: 'gpt-4o',
name: 'GPT-4o',
@@ -2666,6 +2674,7 @@ suite('CopilotAgent', () => {
const agent = createTestAgent(disposables, { copilotClient: client });
const first = {
hasActiveTurn: false,
usesStaticGitHubToken: true,
updates: [] as Array<{ host: string; token: string }>,
async updateGitHubCredentials(host: string, token: string) {
this.updates.push({ host, token });
@@ -2675,6 +2684,7 @@ suite('CopilotAgent', () => {
} satisfies ICredentialUpdateSession & { updates: Array<{ host: string; token: string }> };
const second = {
hasActiveTurn: false,
usesStaticGitHubToken: true,
updates: [] as Array<{ host: string; token: string }>,
async updateGitHubCredentials(host: string, token: string) {
this.updates.push({ host, token });
@@ -2688,15 +2698,20 @@ suite('CopilotAgent', () => {
setDefaultSessionStub(agent, 'second', second);
await agent.authenticate('https://api.github.com', 'model-token-a');
await agent.authenticate('https://api.github.com', 'model-token-a');
await agent.authenticate('https://api.github.com', 'model-token-a', 7200);
assert.deepStrictEqual({
firstUpdates: first.updates,
secondUpdates: second.updates,
stops: client.stopCallCount,
}, {
firstUpdates: [{ host: 'https://github.com', token: 'model-token-a' }],
secondUpdates: [{ host: 'https://github.com', token: 'model-token-a' }],
stops: 0,
firstUpdates: [
{ host: 'https://github.com', token: 'model-token-a' },
],
secondUpdates: [
{ host: 'https://github.com', token: 'model-token-a' },
],
stops: 1,
});
} finally {
await disposeAgent(agent);
@@ -10194,8 +10209,57 @@ suite('CopilotAgent', () => {
assert.deepStrictEqual({
configToken: capturedConfig?.gitHubToken,
hasTokenProvider: capturedConfig?.gitHubTokenProvider !== undefined,
}, {
configToken: 'gh-token-abc',
hasTokenProvider: false,
});
} finally {
await disposeAgent(agent);
}
});
test('materialization uses refreshable GitHub credentials when the lifetime is known', async () => {
const sessionDataService = disposables.add(new TestSessionDataService());
const client = new TestCopilotClient([]);
const mockSession = new MockCopilotSession();
let capturedConfig: Parameters<ITestCopilotClient['createSession']>[0] | undefined;
const agent = createTestAgent(disposables, { sessionDataService, copilotClient: client });
client.createSession = async config => {
capturedConfig = config;
return mockSession as unknown as CopilotSession;
};
try {
await agent.authenticate('https://api.github.com', 'initial-token', 3600);
const result = await provisionSession(agent, {
session: AgentSession.uri('copilotcli', 'refreshable-token-session'),
workingDirectories: [URI.file('/workspace')],
});
await agent.chats.sendMessage(defaultChatUri(result.session), 'hello', undefined, undefined, undefined, undefined, exactChatContext(result.session, defaultChatUri(result.session), result.session));
const provider = capturedConfig?.gitHubTokenProvider;
assert.ok(provider);
const refresh = provider({ host: 'github.com', sessionId: 'refreshable-token-session', reason: 'refresh' });
const authenticationRequired = agent.authenticationRequired.get();
await agent.authenticate('https://api.github.com', 'refreshed-token', 7200);
await refresh;
assert.deepStrictEqual({
gitHubToken: capturedConfig?.gitHubToken,
hasTokenProvider: capturedConfig?.gitHubTokenProvider !== undefined,
authenticationRequired,
credentialUpdates: mockSession.gitHubCredentialUpdates,
clientStops: client.stopCallCount,
}, {
gitHubToken: undefined,
hasTokenProvider: true,
authenticationRequired: {
resource: GITHUB_COPILOT_PROTECTED_RESOURCE,
reason: AuthRequiredReason.Expired,
},
credentialUpdates: [],
clientStops: 0,
});
} finally {
await disposeAgent(agent);
@@ -45,6 +45,7 @@ import { toHostSnapshotAttachmentMeta } from '../../common/meta/agentSnapshotAtt
import { STREAMING_TOOL_DISPLAY_INTERVAL_MS } from '../../common/streamingToolCallDisplay.js';
import { CustomizationEnablementKind, CustomizationType, McpAuthRequiredReason, McpServerStatus, type Customization, type McpServerCustomization } from '../../common/state/protocol/channels-session/state.js';
import { CopilotAgentSession, type ICopilotWorkingDirectoryChangeTransaction } from '../../node/copilot/copilotAgentSession.js';
import { CopilotGitHubCredentials, CopilotGitHubSessionCredentials } from '../../node/copilot/copilotGitHubCredentials.js';
import { buildNonPtyShellTerminalUri } from '../../node/copilot/copilotNonPtyShellTerminals.js';
import { ShellManager } from '../../node/copilot/copilotShellTools.js';
import { buildMcpChannel } from '../../node/shared/mcpCustomizationController.js';
@@ -807,12 +808,12 @@ async function createAgentSession(disposables: DisposableStore, options?: {
/** Platform used to compute the SDK sandbox policy. Defaults to `'linux'` so sandbox tests are deterministic. */
platform?: NodeJS.Platform;
githubToken?: string;
githubCredentials?: CopilotGitHubSessionCredentials;
copilotApiEndpoint?: string;
gitService?: IAgentHostGitService;
gitHubEndpointService?: IAgentHostGitHubEndpointService;
restrictedTelemetryContext?: IRestrictedTelemetryContext;
restrictedTelemetryContextError?: Error;
isLaunchTokenCurrent?: () => boolean;
onTurnEnded?: () => void;
modelId?: string;
enableDevelopmentErrorInjection?: boolean;
@@ -879,7 +880,7 @@ async function createAgentSession(disposables: DisposableStore, options?: {
resolvedAgentName: undefined,
snapshot: options?.clientSnapshot ?? { tools: [], plugins: [], mcpServers: {} },
shellManager: options?.shellManager,
githubToken: options?.githubToken,
githubCredentials: options?.githubCredentials ?? CopilotGitHubSessionCredentials.fromToken(options?.githubToken),
isEphemeral: options?.isEphemeral,
hasScopedEditSurface: options?.hasScopedEditSurface,
};
@@ -1104,7 +1105,6 @@ async function createAgentSession(disposables: DisposableStore, options?: {
customizationDirectory: options?.customizationDirectory,
serverToolHost: options?.serverToolHost,
platform: options?.platform ?? 'linux',
isLaunchTokenCurrent: options?.isLaunchTokenCurrent,
onTurnEnded: options?.onTurnEnded,
enableDevelopmentErrorInjection: options?.enableDevelopmentErrorInjection ?? true,
realpath: options?.realpath,
@@ -13205,9 +13205,10 @@ Use the attached image as context.
});
test('drops an in-flight capture when the launch token is no longer current', async () => {
let tokenCurrent = true;
const workingDirectory = URI.file('/repo');
const telemetryService = new CapturingRestrictedTelemetryService();
const githubCredentials = disposables.add(new CopilotGitHubCredentials());
githubCredentials.update('github-token', 7200);
const gitService: IAgentHostGitService = {
...createNoopGitService(),
getRepositoryRoot: async () => workingDirectory,
@@ -13222,8 +13223,7 @@ Use the attached image as context.
workingDirectory,
gitService,
telemetryService,
githubToken: 'github-token',
isLaunchTokenCurrent: () => tokenCurrent,
githubCredentials: githubCredentials.forSession(),
restrictedTelemetryContext: {
restrictedTelemetryEnabled: true,
trackingId: 'tracking-id',
@@ -13235,7 +13235,7 @@ Use the attached image as context.
},
});
mockSession.fire('assistant.turn_start', { turnId: 'root-turn' });
tokenCurrent = false;
githubCredentials.update('replacement-token', 7200);
await timeout(0);
assert.deepStrictEqual(telemetryService.events.filter(event => event.eventName === 'request.repoInfo'), []);
@@ -0,0 +1,93 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
import assert from 'assert';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { CopilotGitHubCredentials } from '../../node/copilot/copilotGitHubCredentials.js';
suite('CopilotGitHubCredentials', () => {
const disposables = ensureNoDisposablesAreLeakedInTestSuite();
test('coalesces refreshes and refreshes short-lived tokens before returning them', async () => {
let now = 1_000_000;
const credentials = disposables.add(new CopilotGitHubCredentials(() => now));
credentials.update('initial-token', 7200);
let refreshRequests = 0;
disposables.add(credentials.onDidRequestRefresh(() => refreshRequests++));
const firstRefresh = credentials.tokenProvider({ host: 'github.com', sessionId: 'session', reason: 'refresh' });
const secondRefresh = credentials.tokenProvider({ host: 'github.com', sessionId: 'session', reason: 'refresh' });
assert.strictEqual(refreshRequests, 1);
now += 1000;
credentials.update('refreshed-token', 7200);
const refreshedTokens = await Promise.all([firstRefresh, secondRefresh]);
credentials.update('short-token', 3600);
const preflightRefresh = credentials.tokenProvider({ host: 'github.com', sessionId: 'session', reason: 'initial' });
assert.strictEqual(refreshRequests, 2);
credentials.update('preflight-refreshed-token', 7200);
assert.deepStrictEqual({
refreshedTokens,
preflightToken: await preflightRefresh,
}, {
refreshedTokens: [
{ kind: 'token', accessToken: 'refreshed-token', expiresIn: 7200 },
{ kind: 'token', accessToken: 'refreshed-token', expiresIn: 7200 },
],
preflightToken: { kind: 'token', accessToken: 'preflight-refreshed-token', expiresIn: 7200 },
});
});
test('cancels a refresh that does not complete in time', async () => {
const credentials = disposables.add(new CopilotGitHubCredentials(() => 1_000_000, 0));
credentials.update('expiring-token', 3600);
let refreshRequests = 0;
disposables.add(credentials.onDidRequestRefresh(() => refreshRequests++));
const token = await credentials.tokenProvider({ host: 'github.com', sessionId: 'session', reason: 'initial' });
assert.deepStrictEqual({ refreshRequests, token }, {
refreshRequests: 1,
token: { kind: 'cancelled' },
});
});
test('captures credential mode for each SDK session', () => {
const credentials = disposables.add(new CopilotGitHubCredentials());
credentials.update('static-token', undefined);
const staticSession = credentials.forSession();
const modeChange = credentials.update('provider-token', 7200);
const providerSession = credentials.forSession();
staticSession.updateStaticToken('updated-static-token');
assert.deepStrictEqual({
modeChange,
staticSession: {
usesStaticToken: staticSession.usesStaticToken,
token: staticSession.token,
options: staticSession.sdkSessionOptions,
},
providerSession: {
usesStaticToken: providerSession.usesStaticToken,
token: providerSession.token,
hasProvider: providerSession.sdkSessionOptions.gitHubTokenProvider !== undefined,
},
}, {
modeChange: { tokenChanged: true, modeChanged: true },
staticSession: {
usesStaticToken: true,
token: 'updated-static-token',
options: { gitHubToken: 'updated-static-token' },
},
providerSession: {
usesStaticToken: false,
token: 'provider-token',
hasProvider: true,
},
});
});
});
@@ -33,6 +33,7 @@ import type { IAgentHostTerminalManager } from '../../node/agentHostTerminalMana
import { ByokLmBridgeRegistry, IByokLmBridgeRegistry } from '../../node/byokLmBridgeRegistry.js';
import { ByokLmProxyService, IByokLmProxyService, type IByokLmProxyHandle } from '../../node/copilot/byokLmProxyService.js';
import { resolveCopilotMcpServerInfo, type ICopilotPluginInfo } from '../../node/copilot/copilotAgent.js';
import { CopilotGitHubSessionCredentials } from '../../node/copilot/copilotGitHubCredentials.js';
import { CopilotSessionLauncher, filterClientToolNames, getCopilotAutoTier, getCopilotReasoningEffort, isCopilotReasoningEffort, resolveByokSessionConfig, normalizeToolFilterPatterns, resolveConfiguredReasoningEffortOverride, resolveCopilotAutoTier, resolveCopilotReasoningEffort, toSdkToolFilterPatterns, type CopilotSessionLaunchPlan, type ICopilotSessionRuntime } from '../../node/copilot/copilotSessionLauncher.js';
import { buildDefaultChatUri } from '../../common/state/sessionState.js';
import type { IAgentHostSessionOpenTelemetry } from '../../node/agentHostSessionOpenTelemetry.js';
@@ -511,7 +512,7 @@ suite('CopilotSessionLauncher shared session config', () => {
disabledRootMcpServers: ['github', 'azure'],
activeClientToolSet: new ActiveClientToolSet(),
shellManager: undefined,
githubToken: undefined,
githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined),
};
const createPlan: CopilotSessionLaunchPlan = {
...basePlan,
@@ -678,7 +679,7 @@ suite('CopilotSessionLauncher resume fallback', () => {
snapshot: { tools: [], plugins: [], mcpServers: {} },
activeClientToolSet: new ActiveClientToolSet(),
shellManager: undefined,
githubToken: undefined,
githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined),
kind: 'resume',
fallback: { model: undefined },
},
@@ -902,7 +903,7 @@ suite('CopilotSessionLauncher GPT-5.6 customizations', () => {
snapshot: { tools: [], plugins: [], mcpServers: {} },
activeClientToolSet: new ActiveClientToolSet(),
shellManager: undefined,
githubToken: undefined,
githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined),
model: { id: 'claude-sonnet-4.5', config: {} },
};
@@ -933,7 +934,7 @@ suite('CopilotSessionLauncher GPT-5.6 customizations', () => {
snapshot: { tools: [], plugins: [], mcpServers: {} },
activeClientToolSet: new ActiveClientToolSet(),
shellManager: undefined,
githubToken: undefined,
githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined),
model: { id: 'claude-sonnet-4.5', config: {} },
};
@@ -963,7 +964,7 @@ suite('CopilotSessionLauncher GPT-5.6 customizations', () => {
snapshot: { tools: [], plugins: [], mcpServers: {} },
activeClientToolSet: new ActiveClientToolSet(),
shellManager: undefined,
githubToken: undefined,
githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined),
model: { id: 'claude-sonnet-4.5', config: {} },
};
@@ -993,7 +994,7 @@ suite('CopilotSessionLauncher GPT-5.6 customizations', () => {
snapshot: { tools: [], plugins: [], mcpServers: {} },
activeClientToolSet: new ActiveClientToolSet(),
shellManager: undefined,
githubToken: undefined,
githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined),
model: { id: 'claude-sonnet-4.5', config: {} },
};
@@ -1020,7 +1021,7 @@ suite('CopilotSessionLauncher GPT-5.6 customizations', () => {
snapshot: { tools: [], plugins: [], mcpServers: {} },
activeClientToolSet: new ActiveClientToolSet(),
shellManager: undefined,
githubToken: undefined,
githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined),
fallback: { model: { id: 'gpt-5.6-sol', config: {} } },
};
@@ -1333,7 +1334,7 @@ suite('CopilotSessionLauncher resume config', () => {
snapshot,
activeClientToolSet: new ActiveClientToolSet(),
shellManager: undefined,
githubToken: 'token',
githubCredentials: CopilotGitHubSessionCredentials.fromToken('token'),
fallback: { model },
};
const runtime = { createClientSdkTools, createServerSdkTools: () => [] };
@@ -1565,7 +1566,7 @@ suite('CopilotSessionLauncher auto tier', () => {
snapshot: { tools: [], plugins: [], mcpServers: {} },
activeClientToolSet: new ActiveClientToolSet(),
shellManager: undefined,
githubToken: undefined,
githubCredentials: CopilotGitHubSessionCredentials.fromToken(undefined),
};
const plan: CopilotSessionLaunchPlan = kind === 'create'
? { ...base, kind: 'create', model }
@@ -85,7 +85,7 @@ export class MockAgent implements IAgent {
readonly respondToPermissionCalls: { requestId: string; approved: boolean }[] = [];
readonly changeModelCalls: { session: URI; model: ModelSelection; chat?: URI }[] = [];
readonly changeAgentCalls: { session: URI; agent: AgentSelection | undefined; chat?: URI }[] = [];
readonly authenticateCalls: { resource: string; token: string }[] = [];
readonly authenticateCalls: { resource: string; token: string; expiresIn?: number }[] = [];
readonly setClientCustomizationsCalls: { clientId: string; customizations: ClientPluginCustomization[] }[] = [];
readonly setClientToolsCalls: { clientId: string; tools: readonly ToolDefinition[] }[] = [];
readonly removeActiveClientCalls: { chat: URI; clientId: string }[] = [];
@@ -401,8 +401,8 @@ export class MockAgent implements IAgent {
async materializeChat(_chat: URI, _context: URI | IAgentChatContext, _providerData: string | undefined): Promise<IAgentCreateChatResult | void> { }
async authenticate(resource: string, token: string): Promise<boolean> {
this.authenticateCalls.push({ resource, token });
async authenticate(resource: string, token: string, expiresIn?: number): Promise<boolean> {
this.authenticateCalls.push({ resource, token, ...(expiresIn === undefined ? {} : { expiresIn }) });
return true;
}
@@ -48,6 +48,9 @@ const _allApiProposals = {
authSessionAudience: {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authSessionAudience.d.ts',
},
authSessionExpiration: {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authSessionExpiration.d.ts',
},
authenticationChallenges: {
proposal: 'https://raw.githubusercontent.com/microsoft/vscode/main/src/vscode-dts/vscode.proposed.authenticationChallenges.d.ts',
},
@@ -166,8 +166,8 @@ export class AgentHostAuthTokenCache {
}
}
type AuthenticationTokenResolution =
| { readonly kind: 'resolved'; readonly token: string }
type AuthenticationSessionResolution =
| { readonly kind: 'resolved'; readonly session: AuthenticationSession }
| { readonly kind: 'signedOut' }
| { readonly kind: 'unavailable' };
@@ -217,7 +217,7 @@ export class AgentHostAuthenticationRecovery {
const commandService = accessor.get(ICommandService);
const logService = accessor.get(ILogService);
const scopes = resource.scopes_supported ?? [];
const resolution = await resolveAuthenticationTokenForResource(
const resolution = await resolveAuthenticationSessionForResource(
URI.parse(resource.resource),
resource.authorization_servers ?? [],
scopes,
@@ -227,23 +227,23 @@ export class AgentHostAuthenticationRecovery {
);
throwIfAuthenticationStale(options);
if (resolution.kind !== 'resolved') {
logAuthenticationTokenResolution(logService, options.logPrefix, resource.resource, resolution);
logAuthenticationSessionResolution(logService, options.logPrefix, resource.resource, resolution);
if (resolution.kind === 'signedOut') {
this._resentTokens.delete(key);
}
return;
}
const token = resolution.token;
const session = resolution.session;
const previousToken = this._resentTokens.get(key);
if (previousToken !== undefined && previousToken === token) {
if (previousToken !== undefined && previousToken === session.accessToken) {
options.authTokenCache?.clear(resource.resource, resource.scopes_supported);
throwIfAuthenticationStale(options);
const interactiveToken = await forceAuthenticationInteractively(authenticationService, commandService, logService, resource, options);
const interactiveSession = await forceAuthenticationInteractively(authenticationService, commandService, logService, resource, options);
throwIfAuthenticationStale(options);
if (interactiveToken) {
this._resentTokens.set(key, interactiveToken);
if (interactiveToken === token) {
if (interactiveSession) {
this._resentTokens.set(key, interactiveSession.accessToken);
if (interactiveSession.accessToken === session.accessToken) {
logService.info(`${options.logPrefix} Interactive authentication completed without a new token for ${resource.resource}`);
}
}
@@ -251,28 +251,28 @@ export class AgentHostAuthenticationRecovery {
}
options.authTokenCache?.clear(resource.resource, resource.scopes_supported);
if (await forwardAuthenticationToken(options, resource.resource, resource.scopes_supported ?? [], token)) {
this._resentTokens.set(key, token);
if (await forwardAuthenticationToken(options, resource.resource, resource.scopes_supported ?? [], session)) {
this._resentTokens.set(key, session.accessToken);
logService.info(`${options.logPrefix} Authenticating for resource: ${resource.resource}`);
}
}
}
/**
* Resolves a bearer token for a protected resource by trying each
* Resolves an authentication session for a protected resource by trying each
* authorization server in order. First attempts an exact scope match,
* then falls back to finding the session whose scopes are the narrowest
* superset of the requested scopes.
*/
export async function resolveTokenForResource(
export async function resolveSessionForResource(
resourceServer: URI,
authorizationServers: readonly string[],
scopes: readonly string[],
authenticationService: IAuthenticationService,
logService: ILogService,
logPrefix: string,
): Promise<string | undefined> {
const resolution = await resolveAuthenticationTokenForResource(
): Promise<AuthenticationSession | undefined> {
const resolution = await resolveAuthenticationSessionForResource(
resourceServer,
authorizationServers,
scopes,
@@ -280,17 +280,17 @@ export async function resolveTokenForResource(
logService,
logPrefix,
);
return resolution.kind === 'resolved' ? resolution.token : undefined;
return resolution.kind === 'resolved' ? resolution.session : undefined;
}
async function resolveAuthenticationTokenForResource(
async function resolveAuthenticationSessionForResource(
resourceServer: URI,
authorizationServers: readonly string[],
scopes: readonly string[],
authenticationService: IAuthenticationService,
logService: ILogService,
logPrefix: string,
): Promise<AuthenticationTokenResolution> {
): Promise<AuthenticationSessionResolution> {
let hasUnavailableProvider = false;
for (const server of authorizationServers) {
const serverUri = URI.parse(server);
@@ -328,7 +328,7 @@ async function resolveAuthenticationTokenForResource(
}
const exactSession = sessions[0];
if (exactSession) {
return { kind: 'resolved', token: exactSession.accessToken };
return { kind: 'resolved', session: exactSession };
}
let allSessions: readonly AuthenticationSession[];
@@ -341,7 +341,7 @@ async function resolveAuthenticationTokenForResource(
continue;
}
const requestedSet = new Set(scopes);
let bestToken: string | undefined;
let bestSession: AuthenticationSession | undefined;
let bestExtraScopes = Infinity;
for (const session of allSessions) {
const sessionScopes = new Set(session.scopes);
@@ -356,12 +356,12 @@ async function resolveAuthenticationTokenForResource(
const extraScopes = sessionScopes.size - requestedSet.size;
if (extraScopes < bestExtraScopes) {
bestExtraScopes = extraScopes;
bestToken = session.accessToken;
bestSession = session;
}
}
}
if (bestToken) {
return { kind: 'resolved', token: bestToken };
if (bestSession) {
return { kind: 'resolved', session: bestSession };
}
}
return hasUnavailableProvider ? { kind: 'unavailable' } : { kind: 'signedOut' };
@@ -372,6 +372,8 @@ export interface IAgentHostAuthenticateRequest {
readonly scopes?: readonly string[];
/** An empty token revokes the credential previously forwarded for this resource and scope set. */
readonly token: string;
/** The access token's remaining lifetime in seconds, when known. */
readonly expiresIn?: number;
}
export interface IAgentHostAuthenticationOptions {
@@ -406,12 +408,19 @@ async function forwardAuthenticationToken(
options: Pick<IAgentHostAuthenticationOptions, 'authTokenCache' | 'authenticate' | 'isCurrent'>,
resource: string,
scopes: readonly string[] | undefined,
token: string,
session: Pick<AuthenticationSession, 'accessToken' | 'expiresIn'> | undefined,
): Promise<boolean> {
throwIfAuthenticationStale(options);
const request = { resource, scopes, token };
const token = session?.accessToken ?? '';
const expiresIn = session?.expiresIn;
const request: IAgentHostAuthenticateRequest = {
resource,
scopes,
token,
...(expiresIn !== undefined && Number.isInteger(expiresIn) && expiresIn > 0 ? { expiresIn } : {}),
};
if (options.authTokenCache) {
return options.authTokenCache.authenticate(resource, scopes, token, () => options.authenticate(request));
return options.authTokenCache.authenticate(resource, scopes ?? [], token, () => options.authenticate(request));
}
await options.authenticate(request);
return true;
@@ -457,7 +466,7 @@ export async function authenticateProtectedResourcesWithToken(
options: Pick<IAgentHostAuthenticationOptions, 'authTokenCache' | 'authenticate' | 'isCurrent'>,
): Promise<void> {
for (const resource of protectedResources) {
await forwardAuthenticationToken(options, resource.resource, resource.scopes_supported, token);
await forwardAuthenticationToken(options, resource.resource, resource.scopes_supported, { accessToken: token });
}
}
@@ -504,24 +513,24 @@ export async function revokeAuthenticationForRemovedSessions(
}
reconciledResources.add(key);
const resolution = await resolveTokenForProtectedResource(authenticationService, logService, resource, options);
const resolution = await resolveSessionForProtectedResource(authenticationService, logService, resource, options);
throwIfAuthenticationStale(options);
if (resolution.kind === 'unavailable') {
logAuthenticationTokenResolution(logService, options.logPrefix, resource.resource, resolution);
logAuthenticationSessionResolution(logService, options.logPrefix, resource.resource, resolution);
continue;
}
if (resolution.kind === 'resolved') {
// Another account still covers this resource; forward it so the host
// swaps credentials instead of losing them. Unchanged tokens are
// deduped by the cache.
if (await forwardAuthenticationToken(options, resource.resource, scopes, resolution.token)) {
if (await forwardAuthenticationToken(options, resource.resource, scopes, resolution.session)) {
logService.info(`${options.logPrefix} Authenticating for resource after session removal: ${resource.resource}`);
}
continue;
}
options.authTokenCache?.clear(resource.resource, scopes);
if (await forwardAuthenticationToken(options, resource.resource, scopes, '')) {
if (await forwardAuthenticationToken(options, resource.resource, scopes, undefined)) {
logService.info(`${options.logPrefix} Clearing authentication for resource after session removal: ${resource.resource}`);
}
}
@@ -576,14 +585,14 @@ async function authenticateProtectedResourceWithServices(
options: IAgentHostAuthenticationOptions,
): Promise<boolean> {
throwIfAuthenticationStale(options);
const resolution = await resolveTokenForProtectedResource(authenticationService, logService, resource, options);
const resolution = await resolveSessionForProtectedResource(authenticationService, logService, resource, options);
throwIfAuthenticationStale(options);
if (resolution.kind !== 'resolved') {
logAuthenticationTokenResolution(logService, options.logPrefix, resource.resource, resolution);
logAuthenticationSessionResolution(logService, options.logPrefix, resource.resource, resolution);
return false;
}
const authenticated = await forwardAuthenticationToken(options, resource.resource, resource.scopes_supported ?? [], resolution.token);
const authenticated = await forwardAuthenticationToken(options, resource.resource, resource.scopes_supported ?? [], resolution.session);
if (!authenticated) {
logService.trace(`${options.logPrefix} Authentication state for ${resource.resource} unchanged; skipping authenticate RPC`);
return false;
@@ -592,13 +601,13 @@ async function authenticateProtectedResourceWithServices(
return true;
}
async function resolveTokenForProtectedResource(
async function resolveSessionForProtectedResource(
authenticationService: IAuthenticationService,
logService: ILogService,
resource: ProtectedResourceMetadata,
options: Pick<IAgentHostAuthenticationOptions, 'logPrefix'>,
): Promise<AuthenticationTokenResolution> {
return resolveAuthenticationTokenForResource(
): Promise<AuthenticationSessionResolution> {
return resolveAuthenticationSessionForResource(
URI.parse(resource.resource),
resource.authorization_servers ?? [],
resource.scopes_supported ?? [],
@@ -608,11 +617,11 @@ async function resolveTokenForProtectedResource(
);
}
function logAuthenticationTokenResolution(
function logAuthenticationSessionResolution(
logService: ILogService,
logPrefix: string,
resource: string,
resolution: Exclude<AuthenticationTokenResolution, { readonly kind: 'resolved' }>,
resolution: Exclude<AuthenticationSessionResolution, { readonly kind: 'resolved' }>,
): void {
if (resolution.kind === 'unavailable') {
logService.info(`${logPrefix} Authentication provider is not ready for resource: ${resource}; deferring authentication`);
@@ -637,7 +646,7 @@ export async function resolveAuthenticationInteractively(
throwIfAuthenticationStale(options);
const resourceUri = URI.parse(resource.resource);
const scopes = resource.scopes_supported ?? [];
const existingToken = await resolveTokenForResource(
const existingSession = await resolveSessionForResource(
resourceUri,
resource.authorization_servers ?? [],
scopes,
@@ -646,8 +655,8 @@ export async function resolveAuthenticationInteractively(
options.logPrefix,
);
throwIfAuthenticationStale(options);
if (existingToken) {
await forwardAuthenticationToken(options, resource.resource, scopes, existingToken);
if (existingSession) {
await forwardAuthenticationToken(options, resource.resource, scopes, existingSession);
logService.info(`${options.logPrefix} Interactive authentication succeeded for ${resource.resource}`);
return true;
}
@@ -664,7 +673,7 @@ async function forceAuthenticationInteractively(
logService: ILogService,
resource: ProtectedResourceMetadata,
options: IAgentHostAuthenticationOptions,
): Promise<string | undefined> {
): Promise<AuthenticationSession | undefined> {
throwIfAuthenticationStale(options);
const scopes = resource.scopes_supported ?? [];
const setupResult = await commandService.executeCommand<IChatSetupResult>(CHAT_SETUP_ACTION_ID, undefined, {
@@ -681,7 +690,7 @@ async function forceAuthenticationInteractively(
if (!setupResult.success) {
throw setupResult.error ?? new Error(localize('agentHost.signInFailed', "Failed to sign in to use GitHub Copilot."));
}
const token = await resolveTokenForResource(
const session = await resolveSessionForResource(
URI.parse(resource.resource),
resource.authorization_servers ?? [],
scopes,
@@ -690,16 +699,16 @@ async function forceAuthenticationInteractively(
options.logPrefix,
);
throwIfAuthenticationStale(options);
if (!token) {
if (!session) {
logService.info(`${options.logPrefix} Interactive authentication did not provide a token for ${resource.resource}`);
return undefined;
}
options.authTokenCache?.clear(resource.resource, scopes);
if (!await forwardAuthenticationToken(options, resource.resource, scopes, token)) {
if (!await forwardAuthenticationToken(options, resource.resource, scopes, session)) {
return undefined;
}
logService.info(`${options.logPrefix} Interactive authentication completed for ${resource.resource}`);
return token;
return session;
}
export async function resolveMcpServerAuthentication(
@@ -884,7 +893,7 @@ async function authenticateMcpSession(
updateAccess: boolean,
agentHost: { readonly authority: string; readonly label: string } | undefined,
): Promise<void> {
await forwardAuthenticationToken(options, options.mcpServerUrl, scopes, session.accessToken);
await forwardAuthenticationToken(options, options.mcpServerUrl, scopes, session);
if (updateAccess) {
authenticationMcpAccessService.updateAllowedMcpServers(providerId, session.account.label, [{ id: options.mcpServerId, name: options.mcpServerName, allowed: true, url: options.mcpServerUrl, agentHost }]);
authenticationMcpService.updateAccountPreference(options.mcpServerId, providerId, session.account);
@@ -21,7 +21,7 @@ import { IAuthenticationMcpUsageService } from '../../../../../services/authenti
import { IAuthenticationService, type AuthenticationSession, type IAuthenticationProvider } from '../../../../../services/authentication/common/authentication.js';
import { IDynamicAuthenticationProviderStorageService } from '../../../../../services/authentication/common/dynamicAuthenticationProviderStorage.js';
import { CHAT_SETUP_ACTION_ID } from '../../../browser/actions/chatActions.js';
import { AgentHostAuthenticationRecovery, authenticateProtectedResources, resolveAuthenticationInteractively, resolveTokenForResource, AgentHostAuthTokenCache, agentHostMcpServerId, resolveMcpServerAuthentication, modelRequiresAgentAuthentication, revokeAuthenticationForRemovedSessions, type IAgentHostAuthenticationOptions } from '../../../browser/agentSessions/agentHost/agentHostAuth.js';
import { AgentHostAuthenticationRecovery, authenticateProtectedResources, resolveAuthenticationInteractively, resolveSessionForResource, AgentHostAuthTokenCache, agentHostMcpServerId, resolveMcpServerAuthentication, modelRequiresAgentAuthentication, revokeAuthenticationForRemovedSessions, type IAgentHostAuthenticationOptions } from '../../../browser/agentSessions/agentHost/agentHostAuth.js';
import { createAgentModelByokMeta } from '../../../../../../platform/agentHost/common/agentModelByokMeta.js';
class TestCommandService extends mock<ICommandService>() {
@@ -46,7 +46,7 @@ function createAuthInstantiationService(disposables: Pick<DisposableStore, 'add'
function createMockAuthService(overrides: {
getOrActivateProviderIdForServer?: (serverUri: URI, resourceUri: URI) => Promise<string | undefined>;
getSessions?: (providerId: string, scopes: string[] | undefined, options: any, activate: boolean) => Promise<readonly { scopes: string[]; accessToken: string }[]>;
getSessions?: (providerId: string, scopes: string[] | undefined, options: any, activate: boolean) => Promise<readonly { scopes: string[]; accessToken: string; expiresIn?: number }[]>;
createSession?: (providerId: string, scopes: string[], options: any) => Promise<{ accessToken: string }>;
createDynamicAuthenticationProvider?: (...args: Parameters<IAuthenticationService['createDynamicAuthenticationProvider']>) => Promise<{ readonly id: string } | undefined>;
getProvider?: IAuthenticationService['getProvider'];
@@ -93,7 +93,7 @@ suite('agentHostMcpServerId', () => {
});
});
suite('resolveTokenForResource', () => {
suite('resolveSessionForResource', () => {
const log = new NullLogService();
const resource = URI.parse('https://api.example.com');
@@ -102,7 +102,7 @@ suite('resolveTokenForResource', () => {
test('returns undefined when no authorization servers provided', async () => {
const authService = createMockAuthService({});
const token = await resolveTokenForResource(resource, [], ['read'], authService, log, 'test');
const token = (await resolveSessionForResource(resource, [], ['read'], authService, log, 'test'))?.accessToken;
assert.strictEqual(token, undefined);
});
@@ -110,7 +110,7 @@ suite('resolveTokenForResource', () => {
const authService = createMockAuthService({
getOrActivateProviderIdForServer: () => Promise.resolve(undefined),
});
const token = await resolveTokenForResource(resource, ['https://auth.example.com'], ['read'], authService, log, 'test');
const token = (await resolveSessionForResource(resource, ['https://auth.example.com'], ['read'], authService, log, 'test'))?.accessToken;
assert.strictEqual(token, undefined);
});
@@ -124,7 +124,7 @@ suite('resolveTokenForResource', () => {
return Promise.resolve([]);
},
});
const token = await resolveTokenForResource(resource, ['https://auth.example.com'], ['read'], authService, log, 'test');
const token = (await resolveSessionForResource(resource, ['https://auth.example.com'], ['read'], authService, log, 'test'))?.accessToken;
assert.strictEqual(token, 'exact-token');
});
@@ -143,7 +143,7 @@ suite('resolveTokenForResource', () => {
]);
},
});
const token = await resolveTokenForResource(resource, ['https://auth.example.com'], ['read'], authService, log, 'test');
const token = (await resolveSessionForResource(resource, ['https://auth.example.com'], ['read'], authService, log, 'test'))?.accessToken;
assert.strictEqual(token, 'narrow-token');
});
@@ -160,7 +160,7 @@ suite('resolveTokenForResource', () => {
]);
},
});
const token = await resolveTokenForResource(resource, ['https://auth.example.com'], ['read'], authService, log, 'test');
const token = (await resolveSessionForResource(resource, ['https://auth.example.com'], ['read'], authService, log, 'test'))?.accessToken;
assert.strictEqual(token, undefined);
});
@@ -176,11 +176,11 @@ suite('resolveTokenForResource', () => {
},
getSessions: () => Promise.resolve([{ scopes: ['read'], accessToken: 'server2-token' }]),
});
const token = await resolveTokenForResource(
const token = (await resolveSessionForResource(
resource,
['https://auth1.example.com', 'https://auth2.example.com'],
['read'], authService, log, 'test',
);
))?.accessToken;
assert.strictEqual(token, 'server2-token');
assert.strictEqual(calls.length, 2);
});
@@ -1137,14 +1137,14 @@ suite('authenticateProtectedResources', () => {
getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'),
getSessions: (_providerId, scopes) => {
if (scopes) {
return Promise.resolve([{ scopes: ['read'], accessToken: 'cached-token' }]);
return Promise.resolve([{ scopes: ['read'], accessToken: 'cached-token', expiresIn: 3600 }]);
}
return Promise.resolve([]);
},
});
const cache = new AgentHostAuthTokenCache();
const requests: { resource: string; scopes?: readonly string[]; token: string }[] = [];
const requests: { resource: string; scopes?: readonly string[]; token: string; expiresIn?: number }[] = [];
const agents = [{ protectedResources: [protectedResource] }] as unknown as readonly AgentInfo[];
const instantiationService = createAuthInstantiationService(disposables, authService);
@@ -1163,6 +1163,25 @@ suite('authenticateProtectedResources', () => {
},
});
assert.deepStrictEqual(requests, [{ resource: protectedResource.resource, scopes: ['read'], token: 'cached-token', expiresIn: 3600 }]);
});
test('forwards a token without a malformed session expiry', async () => {
const authService = createMockAuthService({
getOrActivateProviderIdForServer: () => Promise.resolve('provider-1'),
getSessions: (_providerId, scopes) => Promise.resolve(scopes ? [{ scopes: ['read'], accessToken: 'cached-token', expiresIn: 0 }] : []),
});
const requests: { resource: string; scopes?: readonly string[]; token: string; expiresIn?: number }[] = [];
const agents = [{ protectedResources: [protectedResource] }] as unknown as readonly AgentInfo[];
const instantiationService = createAuthInstantiationService(disposables, authService);
await instantiationService.invokeFunction(authenticateProtectedResources, agents, {
logPrefix: '[AgentHost]',
authenticate: async request => {
requests.push(request);
},
});
assert.deepStrictEqual(requests, [{ resource: protectedResource.resource, scopes: ['read'], token: 'cached-token' }]);
});
@@ -34,6 +34,7 @@ export interface AuthenticationSession {
account: AuthenticationSessionAccount;
scopes: ReadonlyArray<string>;
idToken?: string;
expiresIn?: number;
}
export interface AuthenticationSessionsChangeEvent {
@@ -0,0 +1,18 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) Microsoft Corporation. All rights reserved.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/
declare module 'vscode' {
export interface AuthenticationSession {
/**
* The access token's remaining lifetime, in seconds, when the authentication provider returns the session.
*
* This corresponds to `expires_in` in an OAuth 2.0 token response. Providers returning
* cached sessions must recompute this value. This is undefined when the authentication
* provider does not know the access token's expiry. When defined, this must be a positive integer.
*/
readonly expiresIn?: number;
}
}