Skip to content

Commit 4cd0cef

Browse files
Fix
Signed-off-by: Roman Nikitenko <rnikiten@redhat.com> fix Signed-off-by: Roman Nikitenko <rnikiten@redhat.com> fix Signed-off-by: Roman Nikitenko <rnikiten@redhat.com> fix start Signed-off-by: Roman Nikitenko <rnikiten@redhat.com> fix Signed-off-by: Roman Nikitenko <rnikiten@redhat.com> fix Signed-off-by: Roman Nikitenko <rnikiten@redhat.com> fix Signed-off-by: Roman Nikitenko <rnikiten@redhat.com> fix Signed-off-by: Roman Nikitenko <rnikiten@redhat.com>
1 parent f53495b commit 4cd0cef

11 files changed

Lines changed: 163 additions & 53 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[
2+
{
3+
"from": "\t\t\tif (tokenResult.kind === 'success') {\n\t\t\t\tthis._logService.info(`Got Copilot token for ${session.account.label}`);\n\t\t\t\tthis._logService.info(`Copilot Chat: ${this._envService.getVersion()}, VS Code: ${this._envService.vscodeVersion}`);\n\t\t\t}\n\t\t\treturn tokenResult;",
4+
"by": "\t\t\tif (tokenResult.kind === 'success') {\n\t\t\t\tthis._logService.info(`Got Copilot token for ${session.account.label}`);\n\t\t\t\tthis._logService.info(`Copilot Chat: ${this._envService.getVersion()}, VS Code: ${this._envService.vscodeVersion}`);\n\t\t\t}\n\n\t\t\tif (tokenResult.kind === 'failure' && tokenResult.reason === 'NotAuthorized' && !tokenResult.notification_id) {\n\t\t\t\tthis._logService.info('Copilot token exchange failed (workspace token cannot be exchanged for Copilot token), triggering sign-in flow');\n\t\t\t\treturn { kind: 'failure', reason: 'GitHubLoginFailed' };\n\t\t\t}\n\n\t\t\treturn tokenResult;"
5+
}
6+
]
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[
2+
{
3+
"from": "\t\t\t\t\t\t}));\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Inline Agents",
4+
"by": "\t\t\t\t\t\t}));\n\t\t\t\t\t\t}\n\n\t\t\t\t\t\t// Proactively clear panel agents when Copilot reports GitHub login failure\n\t\t\t\t\t\t// (e.g. workspace PAT cannot be exchanged for a Copilot token)\n\t\t\t\t\t\tconst gitHubLoginFailedKey = 'github.copilot.interactiveSession.gitHubLoginFailed';\n\t\t\t\t\t\tconst checkGitHubLoginFailed = () => {\n\t\t\t\t\t\t\tif (this.contextKeyService.getContextKeyValue<boolean>(gitHubLoginFailedKey)) {\n\t\t\t\t\t\t\t\tconst panelAgentHasGuidance = chatViewsWelcomeRegistry.get().some(descriptor => this.contextKeyService.contextMatchesRules(descriptor.when));\n\t\t\t\t\t\t\t\tif (panelAgentHasGuidance) {\n\t\t\t\t\t\t\t\t\tthis.logService.error('[chat setup] GitHub login failed detected, clearing panel agent registration to show welcome view.');\n\t\t\t\t\t\t\t\t\tpanelAgentDisposables.dispose();\n\t\t\t\t\t\t\t\t}\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t};\n\t\t\t\t\t\tpanelAgentDisposables.add(this.contextKeyService.onDidChangeContext(e => {\n\t\t\t\t\t\t\tif (e.affectsSome(new Set([gitHubLoginFailedKey]))) {\n\t\t\t\t\t\t\t\tcheckGitHubLoginFailed();\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\t}));\n\t\t\t\t\t\tcheckGitHubLoginFailed();\n\n\t\t\t\t\t\t// Inline Agents"
5+
}
6+
]

code/extensions/che-api/src/api/github-service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,4 +34,7 @@ export interface GithubService {
3434

3535
getUser(): Promise<GithubUser>;
3636
getTokenScopes(token: string): Promise<string[]>;
37+
38+
/** True when the active token comes from the device-authentication secret (OAuth), not a workspace PAT. */
39+
isDeviceAuthToken(): Promise<boolean>;
3740
}

code/extensions/che-api/src/impl/github-service-impl.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,10 @@ export class GithubServiceImpl implements GithubService {
6767
return result.scopes;
6868
}
6969

70+
async isDeviceAuthToken(): Promise<boolean> {
71+
return (await this.getDeviceAuthToken()) !== undefined;
72+
}
73+
7074
private async fetchGithubUser(token: string): Promise<{ user: GithubUser; scopes: string[] }> {
7175
try {
7276
this.logger.info('Github Service: fetching GitHub user using fetch...');

code/extensions/che-github-authentication/src/device-authentication.ts

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -40,25 +40,23 @@ export class DeviceAuthentication {
4040
const scopeString = sortedScopes.join(' ');
4141
this.logger.info(`Device Authentication: running interactive flow for scopes: ${scopeString}`);
4242

43-
const existingSessions = await this.gitHubAuthProvider.getSessions();
44-
this.logger.info(`Device Authentication: found ${existingSessions.length} existing sessions to clear`);
43+
const token = await vscode.commands.executeCommand<string>('github-authentication.device-code-flow', scopeString);
44+
if (!token) {
45+
throw new Error('Device authentication was cancelled or failed');
46+
}
47+
48+
this.logger.info(`Device Authentication: token for scopes: ${scopeString} has been generated successfully`);
4549

50+
const existingSessions = await this.gitHubAuthProvider.getSessions();
51+
this.logger.info(`Device Authentication: clearing ${existingSessions.length} existing sessions`);
4652
for (const session of existingSessions) {
4753
try {
4854
await this.gitHubAuthProvider.removeSession(session.id);
49-
this.logger.info(`Device Authentication: removed session with scopes: ${session.scopes}`);
5055
} catch (e) {
51-
console.warn(e.message);
52-
this.logger.warn(`Device Authentication: an error happened at removing a session with scopes: ${session.scopes}`);
56+
this.logger.warn(`Device Authentication: failed to remove session: ${e.message}`);
5357
}
5458
}
5559

56-
const token = await vscode.commands.executeCommand<string>('github-authentication.device-code-flow', scopeString);
57-
if (!token) {
58-
throw new Error('Device authentication was cancelled or failed');
59-
}
60-
61-
this.logger.info(`Device Authentication: token for scopes: ${scopeString} has been generated successfully`);
6260
await this.githubService.persistDeviceAuthToken(token);
6361
return token;
6462
}

code/extensions/che-github-authentication/src/extension.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,11 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
4747
const deviceAuthentication = container.get(DeviceAuthentication);
4848
authenticationProvider.setDeviceAuthentication(deviceAuthentication);
4949

50+
await authenticationProvider.hydrateFromK8sToken();
51+
5052
vscode.authentication.registerAuthenticationProvider('github', 'GitHub', authenticationProvider);
5153

52-
await authenticationProvider.hydrateFromK8sToken();
54+
await authenticationProvider.notifyExistingSessions();
5355
}
5456

5557
export function deactivate(): void {

code/extensions/che-github-authentication/src/github.ts

Lines changed: 85 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import type { DeviceAuthentication } from './device-authentication';
1717
import { ErrorHandler } from './error-handler';
1818
import { ExtensionContext } from './extension-context';
1919
import { Logger } from './logger';
20-
import { arrayEquals, getMatchingHydrationScopeBundles, hasAllScopes, isUnauthorizedError } from './utils';
20+
import { getMatchingHydrationScopeBundles, hasAllScopes, isUnauthorizedError, isWorkspacePatSession, sessionMatchesRequestedScopes, WORKSPACE_PAT_SCOPE } from './utils';
2121

2222
export interface GithubUser {
2323
login: string;
@@ -32,6 +32,7 @@ export interface GithubService {
3232
removeDeviceAuthToken(): Promise<void>;
3333
getUser(): Promise<GithubUser>;
3434
getTokenScopes(token: string): Promise<string[]>;
35+
isDeviceAuthToken(): Promise<boolean>;
3536
}
3637

3738
@injectable()
@@ -59,13 +60,28 @@ export class GitHubAuthProvider implements vscode.AuthenticationProvider {
5960
this.deviceAuthentication = deviceAuthentication;
6061
}
6162

63+
async notifyExistingSessions(): Promise<void> {
64+
const sessions = await this.sessionsPromise;
65+
if (sessions.length > 0) {
66+
this.logger.info(`GitHubAuthProvider: notifying about ${sessions.length} existing session(s)`);
67+
this.sessionChangeEmitter.fire({ added: sessions, removed: [], changed: [] });
68+
}
69+
}
70+
6271
async hydrateFromK8sToken(): Promise<void> {
6372
let sessions = await this.sessionsPromise;
6473
if (sessions.length > 0) {
6574
try {
6675
await this.githubService.getTokenScopes(sessions[0].accessToken);
67-
this.logger.trace('GitHubAuthProvider: existing session token is valid');
68-
return;
76+
const isDeviceAuthToken = await this.githubService.isDeviceAuthToken();
77+
const sessionsNeedRetag = isDeviceAuthToken
78+
? sessions.some(session => isWorkspacePatSession(session.scopes))
79+
: sessions.some(session => !isWorkspacePatSession(session.scopes));
80+
if (!sessionsNeedRetag) {
81+
this.logger.info('GitHubAuthProvider: existing session token is valid');
82+
return;
83+
}
84+
this.logger.info('GitHubAuthProvider: re-hydrating sessions to match current token source');
6985
} catch (error) {
7086
if (isUnauthorizedError(error)) {
7187
this.logger.warn('GitHubAuthProvider: existing session token is not valid, clearing sessions');
@@ -74,57 +90,71 @@ export class GitHubAuthProvider implements vscode.AuthenticationProvider {
7490
this.sessionChangeEmitter.fire({ added: [], removed, changed: [] });
7591
sessions = [];
7692
} else {
77-
this.logger.trace(`GitHubAuthProvider: session validation skipped: ${(error as Error).message}`);
93+
this.logger.warn(`GitHubAuthProvider: session validation skipped: ${(error as Error).message}`);
7894
return;
7995
}
8096
}
8197
}
8298

83-
try {
84-
const token = await this.githubService.getToken();
85-
const tokenScopes = await this.githubService.getTokenScopes(token);
86-
if (tokenScopes.length === 0) {
87-
this.logger.trace('GitHubAuthProvider: hydrate skipped, token has no scopes');
88-
return;
89-
}
99+
await this.tryHydrate(3, 500);
100+
}
90101

91-
const githubUser = await this.githubService.getUser();
92-
const matchingBundles = getMatchingHydrationScopeBundles(tokenScopes);
93-
if (matchingBundles.length === 0) {
94-
this.logger.trace('GitHubAuthProvider: hydrate skipped, token scopes match no known bundle');
95-
return;
96-
}
102+
private async tryHydrate(maxAttempts: number, delayMs: number): Promise<void> {
103+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
104+
try {
105+
const token = await this.githubService.getToken();
106+
const tokenScopes = await this.githubService.getTokenScopes(token);
107+
if (tokenScopes.length === 0) {
108+
this.logger.info('GitHubAuthProvider: hydrate skipped, token has no scopes');
109+
return;
110+
}
97111

98-
const account = { label: githubUser.login, id: githubUser.id.toString() };
99-
const hydratedSessions = matchingBundles.map(scopes => ({
100-
id: v4(),
101-
accessToken: token,
102-
account,
103-
scopes,
104-
}));
105-
106-
await this.storeSessions(hydratedSessions);
107-
this.sessionChangeEmitter.fire({ added: hydratedSessions, removed: [], changed: [] });
108-
this.logger.info(`GitHubAuthProvider: hydrated ${hydratedSessions.length} session(s) from K8s token`);
109-
} catch (error) {
110-
if (isUnauthorizedError(error)) {
111-
this.logger.warn('GitHubAuthProvider: hydrate failed, token is not valid');
112-
} else {
113-
this.logger.trace(`GitHubAuthProvider: hydrate skipped: ${(error as Error).message}`);
112+
const githubUser = await this.githubService.getUser();
113+
const matchingBundles = getMatchingHydrationScopeBundles(tokenScopes);
114+
if (matchingBundles.length === 0) {
115+
this.logger.info('GitHubAuthProvider: hydrate skipped, token scopes match no known bundle');
116+
return;
117+
}
118+
119+
const isDeviceAuthToken = await this.githubService.isDeviceAuthToken();
120+
const account = { label: githubUser.login, id: githubUser.id.toString() };
121+
const hydratedSessions = matchingBundles.map(scopes => ({
122+
id: v4(),
123+
accessToken: token,
124+
account,
125+
scopes: isDeviceAuthToken ? scopes : [...scopes, WORKSPACE_PAT_SCOPE],
126+
}));
127+
128+
await this.storeSessions(hydratedSessions);
129+
this.sessionChangeEmitter.fire({ added: hydratedSessions, removed: [], changed: [] });
130+
const tokenSource = isDeviceAuthToken ? 'device authentication' : 'workspace PAT';
131+
this.logger.info(`GitHubAuthProvider: hydrated ${hydratedSessions.length} session(s) from K8s ${tokenSource}`);
132+
return;
133+
} catch (error) {
134+
if (isUnauthorizedError(error)) {
135+
this.logger.warn('GitHubAuthProvider: hydrate failed, token is not valid');
136+
return;
137+
}
138+
if (attempt < maxAttempts) {
139+
this.logger.info(`GitHubAuthProvider: hydrate attempt ${attempt}/${maxAttempts} failed: ${(error as Error).message}, retrying in ${delayMs}ms`);
140+
await new Promise(resolve => setTimeout(resolve, delayMs));
141+
} else {
142+
this.logger.warn(`GitHubAuthProvider: hydrate failed after ${maxAttempts} attempts: ${(error as Error).message}`);
143+
}
114144
}
115145
}
116146
}
117147

118148
async getSessions(sessionScopes?: string[]): Promise<vscode.AuthenticationSession[]> {
119-
this.logger.trace(`GitHubAuthProvider: GET SESSIONS for scopes: ${sessionScopes}`);
149+
this.logger.info(`GitHubAuthProvider: GET SESSIONS for scopes: ${sessionScopes}`);
120150

121151
const sessions = await this.sessionsPromise;
122152
const sortedScopes = sessionScopes ? [...sessionScopes].sort() : [];
123153
const filteredSessions = sortedScopes.length
124-
? sessions.filter(session => arrayEquals([...session.scopes].sort(), sortedScopes))
154+
? sessions.filter(session => sessionMatchesRequestedScopes(session.scopes, sortedScopes))
125155
: [...sessions];
126156

127-
this.logger.trace(`GitHubAuthProvider: GET sessions - found ${filteredSessions.length} sessions for scopes: ${sessionScopes}`);
157+
this.logger.info(`GitHubAuthProvider: GET sessions - found ${filteredSessions.length} sessions for scopes: ${sessionScopes}`);
128158
return filteredSessions;
129159
}
130160

@@ -160,14 +190,15 @@ export class GitHubAuthProvider implements vscode.AuthenticationProvider {
160190
}
161191

162192
const sessions = await this.sessionsPromise;
193+
const isDeviceAuthToken = await this.githubService.isDeviceAuthToken();
163194
const session: vscode.AuthenticationSession = {
164195
id: v4(),
165196
accessToken: token,
166197
account: { label: githubUser.login, id: githubUser.id.toString() },
167-
scopes,
198+
scopes: isDeviceAuthToken ? scopes : [...scopes, WORKSPACE_PAT_SCOPE],
168199
};
169200

170-
const sessionIndex = sessions.findIndex(s => arrayEquals([...s.scopes].sort(), sortedScopes));
201+
const sessionIndex = sessions.findIndex(s => sessionMatchesRequestedScopes(s.scopes, sortedScopes));
171202
const removed: vscode.AuthenticationSession[] = [];
172203
const updatedSessions = [...sessions];
173204
if (sessionIndex > -1) {
@@ -188,15 +219,28 @@ export class GitHubAuthProvider implements vscode.AuthenticationProvider {
188219
const token = await this.githubService.getToken();
189220
const existingScopes = await this.githubService.getTokenScopes(token);
190221
if (!hasAllScopes(existingScopes, sortedScopes)) {
191-
this.logger.info(`GitHubAuthProvider: token lacks required scopes, starting device flow`);
222+
this.logger.info('GitHubAuthProvider: token lacks required scopes, starting device flow');
192223
return await this.getDeviceAuthentication().runInteractiveFlow(sortedScopes);
193224
}
225+
226+
const isDeviceAuth = await this.githubService.isDeviceAuthToken();
227+
if (!isDeviceAuth) {
228+
const sessions = await this.sessionsPromise;
229+
const hasExistingSession = sessions.some(s =>
230+
sessionMatchesRequestedScopes(s.scopes, sortedScopes)
231+
);
232+
if (hasExistingSession) {
233+
this.logger.info('GitHubAuthProvider: PAT session already exists for requested scopes, starting device auth flow');
234+
return await this.getDeviceAuthentication().runInteractiveFlow(sortedScopes);
235+
}
236+
}
237+
194238
return token;
195239
} catch (error) {
196240
if (isUnauthorizedError(error)) {
197-
this.logger.info(`GitHubAuthProvider: token is not valid, starting device flow`);
241+
this.logger.info('GitHubAuthProvider: token is not valid, starting device flow');
198242
} else {
199-
this.logger.info(`GitHubAuthProvider: no token available, starting device flow`);
243+
this.logger.info('GitHubAuthProvider: no token available, starting device flow');
200244
}
201245
return await this.getDeviceAuthentication().runInteractiveFlow(sortedScopes);
202246
}

code/extensions/che-github-authentication/src/utils.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,24 @@ export function hasAllScopes(existingScopes: string[], requestedScopes: string[]
3838
return requestedScopes.every(scope => existingScopes.includes(scope));
3939
}
4040

41+
/**
42+
* Marker scope on sessions backed by a workspace PAT. Copilot requests exact scope lists and
43+
* will not match these sessions; other extensions use superset matching in getSessions.
44+
*/
45+
export const WORKSPACE_PAT_SCOPE = 'che:workspace-pat';
46+
47+
export function isWorkspacePatSession(scopes: readonly string[]): boolean {
48+
return scopes.includes(WORKSPACE_PAT_SCOPE);
49+
}
50+
51+
export function sessionMatchesRequestedScopes(sessionScopes: readonly string[], requestedScopes: readonly string[]): boolean {
52+
if (isWorkspacePatSession(sessionScopes)) {
53+
const tokenScopes = sessionScopes.filter(scope => scope !== WORKSPACE_PAT_SCOPE);
54+
return hasAllScopes(tokenScopes, [...requestedScopes]);
55+
}
56+
return arrayEquals([...sessionScopes].sort(), [...requestedScopes].sort());
57+
}
58+
4159
/** Scope bundles used by Copilot / github / GHPRI / Agent Host — session keys match vanilla createSession. */
4260
export const HYDRATION_SCOPE_BUNDLES: readonly string[][] = [
4361
['read:user', 'user:email', 'repo', 'workflow', 'project', 'read:org'],

code/extensions/copilot/src/platform/authentication/vscode-node/copilotTokenManager.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,12 @@ export class VSCodeCopilotTokenManager extends BaseCopilotTokenManager {
8585
this._logService.info(`Got Copilot token for ${session.account.label}`);
8686
this._logService.info(`Copilot Chat: ${this._envService.getVersion()}, VS Code: ${this._envService.vscodeVersion}`);
8787
}
88+
89+
if (tokenResult.kind === 'failure' && tokenResult.reason === 'NotAuthorized' && !tokenResult.notification_id) {
90+
this._logService.info('Copilot token exchange failed (workspace token cannot be exchanged for Copilot token), triggering sign-in flow');
91+
return { kind: 'failure', reason: 'GitHubLoginFailed' };
92+
}
93+
8894
return tokenResult;
8995
} else {
9096
this._logService.info(`Allowing anonymous access with devDeviceId`);

code/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,25 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr
138138
}));
139139
}
140140

141+
// Proactively clear panel agents when Copilot reports GitHub login failure
142+
// (e.g. workspace PAT cannot be exchanged for a Copilot token)
143+
const gitHubLoginFailedKey = 'github.copilot.interactiveSession.gitHubLoginFailed';
144+
const checkGitHubLoginFailed = () => {
145+
if (this.contextKeyService.getContextKeyValue<boolean>(gitHubLoginFailedKey)) {
146+
const panelAgentHasGuidance = chatViewsWelcomeRegistry.get().some(descriptor => this.contextKeyService.contextMatchesRules(descriptor.when));
147+
if (panelAgentHasGuidance) {
148+
this.logService.error('[chat setup] GitHub login failed detected, clearing panel agent registration to show welcome view.');
149+
panelAgentDisposables.dispose();
150+
}
151+
}
152+
};
153+
panelAgentDisposables.add(this.contextKeyService.onDidChangeContext(e => {
154+
if (e.affectsSome(new Set([gitHubLoginFailedKey]))) {
155+
checkGitHubLoginFailed();
156+
}
157+
}));
158+
checkGitHubLoginFailed();
159+
141160
// Inline Agents
142161
disposables.add(SetupAgent.registerDefaultAgents(this.instantiationService, ChatAgentLocation.Terminal, ChatModeKind.Ask, context, controller).disposable);
143162
disposables.add(SetupAgent.registerDefaultAgents(this.instantiationService, ChatAgentLocation.Notebook, ChatModeKind.Ask, context, controller).disposable);

0 commit comments

Comments
 (0)