@theia/ai-copilot 1.75.0 → 1.76.0-next.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +86 -14
  2. package/lib/browser/copilot-auth-dialog.d.ts.map +1 -1
  3. package/lib/browser/copilot-auth-dialog.js +12 -4
  4. package/lib/browser/copilot-auth-dialog.js.map +1 -1
  5. package/lib/browser/copilot-frontend-application-contribution.d.ts +15 -1
  6. package/lib/browser/copilot-frontend-application-contribution.d.ts.map +1 -1
  7. package/lib/browser/copilot-frontend-application-contribution.js +52 -24
  8. package/lib/browser/copilot-frontend-application-contribution.js.map +1 -1
  9. package/lib/common/copilot-auth-service.d.ts +27 -24
  10. package/lib/common/copilot-auth-service.d.ts.map +1 -1
  11. package/lib/common/copilot-language-models-manager.d.ts +3 -21
  12. package/lib/common/copilot-language-models-manager.d.ts.map +1 -1
  13. package/lib/common/copilot-language-models-manager.js +1 -13
  14. package/lib/common/copilot-language-models-manager.js.map +1 -1
  15. package/lib/common/copilot-preferences.d.ts +1 -0
  16. package/lib/common/copilot-preferences.d.ts.map +1 -1
  17. package/lib/common/copilot-preferences.js +24 -6
  18. package/lib/common/copilot-preferences.js.map +1 -1
  19. package/lib/common/index.d.ts +0 -1
  20. package/lib/common/index.d.ts.map +1 -1
  21. package/lib/common/index.js +0 -1
  22. package/lib/common/index.js.map +1 -1
  23. package/lib/node/copilot-auth-service-impl.d.ts +25 -16
  24. package/lib/node/copilot-auth-service-impl.d.ts.map +1 -1
  25. package/lib/node/copilot-auth-service-impl.js +67 -183
  26. package/lib/node/copilot-auth-service-impl.js.map +1 -1
  27. package/lib/node/copilot-auth-service-impl.spec.js +104 -168
  28. package/lib/node/copilot-auth-service-impl.spec.js.map +1 -1
  29. package/lib/node/copilot-backend-module.d.ts.map +1 -1
  30. package/lib/node/copilot-backend-module.js +10 -2
  31. package/lib/node/copilot-backend-module.js.map +1 -1
  32. package/lib/node/copilot-cli-auth-provider.d.ts +146 -0
  33. package/lib/node/copilot-cli-auth-provider.d.ts.map +1 -0
  34. package/lib/node/copilot-cli-auth-provider.js +430 -0
  35. package/lib/node/copilot-cli-auth-provider.js.map +1 -0
  36. package/lib/node/copilot-cli-auth-provider.spec.d.ts +2 -0
  37. package/lib/node/copilot-cli-auth-provider.spec.d.ts.map +1 -0
  38. package/lib/node/copilot-cli-auth-provider.spec.js +249 -0
  39. package/lib/node/copilot-cli-auth-provider.spec.js.map +1 -0
  40. package/lib/node/copilot-cli-locator.d.ts +120 -0
  41. package/lib/node/copilot-cli-locator.d.ts.map +1 -0
  42. package/lib/node/copilot-cli-locator.js +348 -0
  43. package/lib/node/copilot-cli-locator.js.map +1 -0
  44. package/lib/node/copilot-cli-locator.spec.d.ts +2 -0
  45. package/lib/node/copilot-cli-locator.spec.d.ts.map +1 -0
  46. package/lib/node/copilot-cli-locator.spec.js +327 -0
  47. package/lib/node/copilot-cli-locator.spec.js.map +1 -0
  48. package/lib/node/copilot-credential-store.d.ts +45 -0
  49. package/lib/node/copilot-credential-store.d.ts.map +1 -0
  50. package/lib/node/copilot-credential-store.js +96 -0
  51. package/lib/node/copilot-credential-store.js.map +1 -0
  52. package/lib/node/copilot-credential-store.spec.d.ts +2 -0
  53. package/lib/node/copilot-credential-store.spec.d.ts.map +1 -0
  54. package/lib/node/copilot-credential-store.spec.js +93 -0
  55. package/lib/node/copilot-credential-store.spec.js.map +1 -0
  56. package/lib/node/copilot-language-models-manager-impl.d.ts +20 -18
  57. package/lib/node/copilot-language-models-manager-impl.d.ts.map +1 -1
  58. package/lib/node/copilot-language-models-manager-impl.js +39 -67
  59. package/lib/node/copilot-language-models-manager-impl.js.map +1 -1
  60. package/lib/node/copilot-language-models-manager-impl.spec.d.ts +2 -0
  61. package/lib/node/copilot-language-models-manager-impl.spec.d.ts.map +1 -0
  62. package/lib/node/copilot-language-models-manager-impl.spec.js +119 -0
  63. package/lib/node/copilot-language-models-manager-impl.spec.js.map +1 -0
  64. package/lib/node/copilot-runtime-env.d.ts +20 -0
  65. package/lib/node/copilot-runtime-env.d.ts.map +1 -0
  66. package/lib/node/copilot-runtime-env.js +49 -0
  67. package/lib/node/copilot-runtime-env.js.map +1 -0
  68. package/lib/node/copilot-sdk-client-provider.d.ts +103 -0
  69. package/lib/node/copilot-sdk-client-provider.d.ts.map +1 -0
  70. package/lib/node/copilot-sdk-client-provider.js +247 -0
  71. package/lib/node/copilot-sdk-client-provider.js.map +1 -0
  72. package/lib/node/copilot-sdk-client-provider.spec.d.ts +2 -0
  73. package/lib/node/copilot-sdk-client-provider.spec.d.ts.map +1 -0
  74. package/lib/node/copilot-sdk-client-provider.spec.js +215 -0
  75. package/lib/node/copilot-sdk-client-provider.spec.js.map +1 -0
  76. package/lib/node/copilot-sdk-language-model.d.ts +85 -0
  77. package/lib/node/copilot-sdk-language-model.d.ts.map +1 -0
  78. package/lib/node/copilot-sdk-language-model.js +301 -0
  79. package/lib/node/copilot-sdk-language-model.js.map +1 -0
  80. package/lib/node/copilot-sdk-language-model.spec.d.ts +2 -0
  81. package/lib/node/copilot-sdk-language-model.spec.d.ts.map +1 -0
  82. package/lib/node/copilot-sdk-language-model.spec.js +302 -0
  83. package/lib/node/copilot-sdk-language-model.spec.js.map +1 -0
  84. package/lib/node/copilot-sdk-loader.d.ts +57 -0
  85. package/lib/node/copilot-sdk-loader.d.ts.map +1 -0
  86. package/lib/node/copilot-sdk-loader.js +133 -0
  87. package/lib/node/copilot-sdk-loader.js.map +1 -0
  88. package/lib/node/copilot-sdk-loader.spec.d.ts +2 -0
  89. package/lib/node/copilot-sdk-loader.spec.d.ts.map +1 -0
  90. package/lib/node/copilot-sdk-loader.spec.js +113 -0
  91. package/lib/node/copilot-sdk-loader.spec.js.map +1 -0
  92. package/lib/node/copilot-sdk-mappers.d.ts +49 -0
  93. package/lib/node/copilot-sdk-mappers.d.ts.map +1 -0
  94. package/lib/node/copilot-sdk-mappers.js +147 -0
  95. package/lib/node/copilot-sdk-mappers.js.map +1 -0
  96. package/lib/node/copilot-sdk-mappers.spec.d.ts +2 -0
  97. package/lib/node/copilot-sdk-mappers.spec.d.ts.map +1 -0
  98. package/lib/node/copilot-sdk-mappers.spec.js +133 -0
  99. package/lib/node/copilot-sdk-mappers.spec.js.map +1 -0
  100. package/lib/node/copilot-sdk-types.d.ts +198 -0
  101. package/lib/node/copilot-sdk-types.d.ts.map +1 -0
  102. package/lib/{common/copilot-oauth-config.js → node/copilot-sdk-types.js} +2 -13
  103. package/lib/node/copilot-sdk-types.js.map +1 -0
  104. package/lib/node/index.d.ts +9 -1
  105. package/lib/node/index.d.ts.map +1 -1
  106. package/lib/node/index.js +9 -1
  107. package/lib/node/index.js.map +1 -1
  108. package/package.json +4 -6
  109. package/src/browser/copilot-auth-dialog.tsx +12 -8
  110. package/src/browser/copilot-frontend-application-contribution.ts +62 -29
  111. package/src/common/copilot-auth-service.ts +28 -24
  112. package/src/common/copilot-language-models-manager.ts +3 -28
  113. package/src/common/copilot-preferences.ts +24 -5
  114. package/src/common/index.ts +0 -1
  115. package/src/node/copilot-auth-service-impl.spec.ts +122 -207
  116. package/src/node/copilot-auth-service-impl.ts +63 -218
  117. package/src/node/copilot-backend-module.ts +11 -2
  118. package/src/node/copilot-cli-auth-provider.spec.ts +315 -0
  119. package/src/node/copilot-cli-auth-provider.ts +444 -0
  120. package/src/node/copilot-cli-locator.spec.ts +386 -0
  121. package/src/node/copilot-cli-locator.ts +363 -0
  122. package/src/node/copilot-credential-store.spec.ts +106 -0
  123. package/src/node/copilot-credential-store.ts +99 -0
  124. package/src/node/copilot-language-models-manager-impl.spec.ts +137 -0
  125. package/src/node/copilot-language-models-manager-impl.ts +51 -85
  126. package/src/node/copilot-runtime-env.ts +46 -0
  127. package/src/node/copilot-sdk-client-provider.spec.ts +253 -0
  128. package/src/node/copilot-sdk-client-provider.ts +241 -0
  129. package/src/node/copilot-sdk-language-model.spec.ts +363 -0
  130. package/src/node/copilot-sdk-language-model.ts +329 -0
  131. package/src/node/copilot-sdk-loader.spec.ts +126 -0
  132. package/src/node/copilot-sdk-loader.ts +142 -0
  133. package/src/node/copilot-sdk-mappers.spec.ts +151 -0
  134. package/src/node/copilot-sdk-mappers.ts +161 -0
  135. package/src/node/copilot-sdk-types.ts +276 -0
  136. package/src/node/index.ts +9 -1
  137. package/lib/common/copilot-oauth-config.d.ts +0 -33
  138. package/lib/common/copilot-oauth-config.d.ts.map +0 -1
  139. package/lib/common/copilot-oauth-config.js.map +0 -1
  140. package/lib/node/copilot-language-model.d.ts +0 -34
  141. package/lib/node/copilot-language-model.d.ts.map +0 -1
  142. package/lib/node/copilot-language-model.js +0 -236
  143. package/lib/node/copilot-language-model.js.map +0 -1
  144. package/lib/node/copilot-language-model.spec.d.ts +0 -2
  145. package/lib/node/copilot-language-model.spec.d.ts.map +0 -1
  146. package/lib/node/copilot-language-model.spec.js +0 -89
  147. package/lib/node/copilot-language-model.spec.js.map +0 -1
  148. package/src/common/copilot-oauth-config.ts +0 -55
  149. package/src/node/copilot-language-model.spec.ts +0 -106
  150. package/src/node/copilot-language-model.ts +0 -273
@@ -21,19 +21,13 @@ export const CopilotAuthService = Symbol('CopilotAuthService');
21
21
  export const CopilotAuthServiceClient = Symbol('CopilotAuthServiceClient');
22
22
 
23
23
  /**
24
- * Response from GitHub's device code endpoint.
24
+ * The device code flow information reported by the Copilot CLI for the UI to display.
25
25
  */
26
26
  export interface DeviceCodeResponse {
27
27
  /** URL where user should enter the code (e.g., https://github.com/login/device) */
28
28
  verification_uri: string;
29
29
  /** Code to display to the user (e.g., XXXX-XXXX) */
30
30
  user_code: string;
31
- /** Device code used for polling */
32
- device_code: string;
33
- /** Polling interval in seconds */
34
- interval: number;
35
- /** Expiration time in seconds */
36
- expires_in: number;
37
31
  }
38
32
 
39
33
  /**
@@ -46,7 +40,10 @@ export interface CopilotAuthState {
46
40
  accountLabel?: string;
47
41
  /** GitHub Enterprise URL if using enterprise */
48
42
  enterpriseUrl?: string;
49
- /** True when credentials were cleared due to a client ID migration */
43
+ /**
44
+ * True when the credentials of a previous version were found and discarded, so that the user can
45
+ * be told why they have to sign in again.
46
+ */
50
47
  migrationRequired?: boolean;
51
48
  }
52
49
 
@@ -58,41 +55,48 @@ export interface CopilotAuthServiceClient {
58
55
  }
59
56
 
60
57
  /**
61
- * Service for handling GitHub Copilot OAuth Device Flow authentication.
58
+ * Service for signing the GitHub Copilot CLI in, which serves all Copilot requests.
59
+ *
60
+ * The sign-in is a device code flow that the CLI runs and polls itself. This service starts it and
61
+ * reports its progress, so that it can be presented like any other device code flow.
62
62
  */
63
63
  export interface CopilotAuthService {
64
64
  /**
65
- * Initiates the OAuth Device Flow.
66
- * Returns device code information for the UI to display.
67
- * @param enterpriseUrl Optional GitHub Enterprise domain
65
+ * Tells the backend where the Copilot CLI is, as configured by the user.
66
+ *
67
+ * The CLI is not shipped with the application and has to be found on the machine hosting the
68
+ * backend, which cannot read the preferences of the user itself.
69
+ * @param path Location of the CLI executable, or `undefined` to search for it
68
70
  */
69
- initiateDeviceFlow(enterpriseUrl?: string): Promise<DeviceCodeResponse>;
71
+ setExecutablePath(path: string | undefined): Promise<void>;
70
72
 
71
73
  /**
72
- * Polls for the access token after user authorizes.
73
- * @param deviceCode The device code from initiateDeviceFlow
74
- * @param interval Polling interval in seconds
74
+ * Starts the sign-in and resolves once the code the user has to enter is known.
75
75
  * @param enterpriseUrl Optional GitHub Enterprise domain
76
- * @returns true if authentication succeeded, false if expired/denied
77
76
  */
78
- pollForToken(deviceCode: string, interval: number, enterpriseUrl?: string): Promise<boolean>;
77
+ startSignIn(enterpriseUrl?: string): Promise<DeviceCodeResponse>;
79
78
 
80
79
  /**
81
- * Get the current authentication state.
80
+ * Waits for the sign-in started by {@link startSignIn} to complete.
81
+ * @returns true if the sign-in succeeded, false if it was denied or expired
82
82
  */
83
- getAuthState(): Promise<CopilotAuthState>;
83
+ waitForSignIn(): Promise<boolean>;
84
84
 
85
85
  /**
86
- * Get the access token for API calls.
87
- * @returns The access token or undefined if not authenticated
86
+ * Aborts a sign-in that is still in progress, if any.
88
87
  */
89
- getAccessToken(): Promise<string | undefined>;
88
+ cancelSignIn(): Promise<void>;
90
89
 
91
90
  /**
92
- * Sign out and clear stored credentials.
91
+ * Discards the stored credentials of this application.
93
92
  */
94
93
  signOut(): Promise<void>;
95
94
 
95
+ /**
96
+ * Get the current authentication state.
97
+ */
98
+ getAuthState(): Promise<CopilotAuthState>;
99
+
96
100
  /**
97
101
  * Set the client to receive auth state change notifications.
98
102
  */
@@ -18,18 +18,6 @@ export const COPILOT_LANGUAGE_MODELS_MANAGER_PATH = '/services/copilot/language-
18
18
  export const CopilotLanguageModelsManager = Symbol('CopilotLanguageModelsManager');
19
19
 
20
20
  export const COPILOT_PROVIDER_ID = 'copilot';
21
- export const COPILOT_API_BASE_URL = 'https://api.githubcopilot.com';
22
-
23
- /**
24
- * Returns the Copilot API base URL, taking an optional GitHub Enterprise domain into account.
25
- */
26
- export function getCopilotApiBaseUrl(enterpriseUrl?: string): string {
27
- if (enterpriseUrl) {
28
- const domain = enterpriseUrl.replace(/^https?:\/\//, '').replace(/\/$/, '');
29
- return `https://copilot-api.${domain}`;
30
- }
31
- return COPILOT_API_BASE_URL;
32
- }
33
21
 
34
22
  export interface CopilotModelDescription {
35
23
  /**
@@ -38,17 +26,9 @@ export interface CopilotModelDescription {
38
26
  */
39
27
  id: string;
40
28
  /**
41
- * The model ID as used by the Copilot API (e.g., 'gpt-4o', 'claude-3.5-sonnet').
29
+ * The model ID as used by the Copilot API (e.g., 'gpt-5.5', 'claude-sonnet-5').
42
30
  */
43
31
  model: string;
44
- /**
45
- * Indicate whether the streaming API shall be used.
46
- */
47
- enableStreaming: boolean;
48
- /**
49
- * Flag to configure whether the model supports structured output.
50
- */
51
- supportsStructuredOutput: boolean;
52
32
  /**
53
33
  * Maximum number of retry attempts when a request fails.
54
34
  */
@@ -64,18 +44,13 @@ export interface CopilotLanguageModelsManager {
64
44
  * Remove language models from the registry.
65
45
  */
66
46
  removeLanguageModels(...modelIds: string[]): void;
67
- /**
68
- * Set the GitHub Enterprise URL for Copilot API requests.
69
- */
70
- setEnterpriseUrl(url: string | undefined): void;
71
47
  /**
72
48
  * Refresh the status of all Copilot models (e.g., after authentication state changes).
73
49
  */
74
50
  refreshModelsStatus(): Promise<void>;
75
51
  /**
76
- * Fetches the list of available model IDs from the Copilot API.
77
- * Requires authentication. Returns an empty array if not authenticated
78
- * or if the API call fails.
52
+ * Fetches the list of available model IDs from the Copilot CLI.
53
+ * Requires the CLI to be signed in. Returns an empty array otherwise or if the call fails.
79
54
  */
80
55
  fetchAvailableModelIds(): Promise<string[]>;
81
56
  }
@@ -20,6 +20,7 @@ import { nls, PreferenceSchema } from '@theia/core';
20
20
  export const COPILOT_ENABLED_PREF = 'ai-features.copilot.enabled';
21
21
  export const COPILOT_MODEL_OVERRIDES_PREF = 'ai-features.copilot.modelOverrides';
22
22
  export const COPILOT_ENTERPRISE_URL_PREF = 'ai-features.copilot.enterpriseUrl';
23
+ export const COPILOT_EXECUTABLE_PATH_PREF = 'ai-features.copilot.executablePath';
23
24
 
24
25
  export const CopilotPreferencesSchema: PreferenceSchema = {
25
26
  properties: {
@@ -28,9 +29,13 @@ export const CopilotPreferencesSchema: PreferenceSchema = {
28
29
  typeDetails: { [MODEL_PROVIDER_TYPE_DETAIL]: { label: 'GitHub Copilot' } satisfies ModelProviderTypeDetail },
29
30
  markdownDescription: nls.localize('theia/ai/copilot/enabled/mdDescription',
30
31
  'Enable the GitHub Copilot provider. When enabled, a status bar entry '
31
- + 'appears for authentication and available models are discovered from your Copilot subscription.'),
32
+ + 'appears for authentication and available models are discovered from your Copilot subscription.\n\n'
33
+ + 'Requests are routed through the official GitHub Copilot CLI, which runs as a background process on the '
34
+ + 'machine hosting the backend. It is given the credentials of your Copilot sign-in and nothing else, so a '
35
+ + 'token in the environment or an existing sign-in of the GitHub CLI is not used.'),
32
36
  title: AI_CORE_PREFERENCES_TITLE,
33
- default: true
37
+ default: true,
38
+ tags: ['experimental']
34
39
  },
35
40
  [COPILOT_MODEL_OVERRIDES_PREF]: {
36
41
  type: 'array',
@@ -42,14 +47,28 @@ export const CopilotPreferencesSchema: PreferenceSchema = {
42
47
  default: [],
43
48
  items: {
44
49
  type: 'string'
45
- }
50
+ },
51
+ tags: ['experimental']
46
52
  },
47
53
  [COPILOT_ENTERPRISE_URL_PREF]: {
48
54
  type: 'string',
49
55
  markdownDescription: nls.localize('theia/ai/copilot/enterpriseUrl/mdDescription',
50
- 'GitHub Enterprise domain for Copilot API (e.g., `github.mycompany.com`). Leave empty for GitHub.com.'),
56
+ 'GitHub Enterprise domain to authenticate and send requests against (e.g., `github.mycompany.com`). '
57
+ + 'Leave empty for GitHub.com.'),
51
58
  title: AI_CORE_PREFERENCES_TITLE,
52
- default: ''
59
+ default: '',
60
+ tags: ['experimental']
61
+ },
62
+ [COPILOT_EXECUTABLE_PATH_PREF]: {
63
+ type: 'string',
64
+ markdownDescription: nls.localize('theia/ai/copilot/executablePath/mdDescription',
65
+ 'Location of the GitHub Copilot CLI executable on the machine hosting the backend. '
66
+ + 'When empty (default), the CLI is searched for in the installation of the application, on the `PATH` '
67
+ + 'and in the global `npm` directory. Set this when the CLI is installed somewhere else.\n\n'
68
+ + 'The CLI is not shipped with the application, install it with `npm install -g @github/copilot`.'),
69
+ title: AI_CORE_PREFERENCES_TITLE,
70
+ default: '',
71
+ tags: ['experimental']
53
72
  }
54
73
  }
55
74
  };
@@ -16,5 +16,4 @@
16
16
 
17
17
  export * from './copilot-language-models-manager';
18
18
  export * from './copilot-auth-service';
19
- export * from './copilot-oauth-config';
20
19
  export * from './copilot-preferences';
@@ -1,5 +1,5 @@
1
1
  // *****************************************************************************
2
- // Copyright (C) 2026 EclipseSource and others.
2
+ // Copyright (C) 2026 EclipseSource GmbH and others.
3
3
  //
4
4
  // This program and the accompanying materials are made available under the
5
5
  // terms of the Eclipse Public License v. 2.0 which is available at
@@ -15,235 +15,150 @@
15
15
  // *****************************************************************************
16
16
 
17
17
  import { expect } from 'chai';
18
- import * as sinon from 'sinon';
19
- import { Container } from '@theia/core/shared/inversify';
20
- import { KeyStoreService } from '@theia/core/lib/common/key-store';
21
- import { CopilotOAuthConfig, DEFAULT_COPILOT_OAUTH_CONFIG } from '../common/copilot-oauth-config';
22
- import { CopilotAuthServiceImpl } from './copilot-auth-service-impl';
23
- import { ILogger } from '@theia/core/lib/common/logger';
24
18
  import { MockLogger } from '@theia/core/lib/common/test/mock-logger';
19
+ import { CopilotAuthState } from '../common/copilot-auth-service';
20
+ import { CopilotAuthServiceImpl } from './copilot-auth-service-impl';
25
21
 
26
- describe('CopilotAuthServiceImpl', () => {
27
-
28
- let authService: CopilotAuthServiceImpl;
29
- let keyStoreService: KeyStoreService;
30
- let getPasswordStub: sinon.SinonStub;
31
-
32
- beforeEach(() => {
33
- const container = new Container();
34
-
35
- getPasswordStub = sinon.stub();
36
- keyStoreService = {
37
- setPassword: sinon.stub().resolves() as KeyStoreService['setPassword'],
38
- getPassword: getPasswordStub.resolves(undefined) as KeyStoreService['getPassword'],
39
- deletePassword: sinon.stub().resolves(true) as KeyStoreService['deletePassword'],
40
- findPassword: sinon.stub().resolves(undefined) as KeyStoreService['findPassword'],
41
- findCredentials: sinon.stub().resolves([]) as KeyStoreService['findCredentials'],
42
- keys: sinon.stub().resolves([]) as KeyStoreService['keys']
43
- };
44
-
45
- container.bind(KeyStoreService).toConstantValue(keyStoreService);
46
- container.bind(CopilotOAuthConfig).toConstantValue(DEFAULT_COPILOT_OAUTH_CONFIG);
47
- container.bind(CopilotAuthServiceImpl).toSelf().inSingletonScope();
48
- container.bind(ILogger).to(MockLogger).inSingletonScope();
49
-
50
- authService = container.get(CopilotAuthServiceImpl);
51
- });
52
-
53
- afterEach(() => {
54
- sinon.restore();
55
- });
56
-
57
- describe('getAccessToken', () => {
58
-
59
- it('should return undefined when no credentials are stored', async () => {
60
- getPasswordStub.resolves(undefined);
61
-
62
- const token = await authService.getAccessToken();
63
- expect(token).to.be.undefined;
64
- });
65
-
66
- it('should return the stored access token directly', async () => {
67
- const oauthToken = 'ghu_test_token';
68
-
69
- getPasswordStub.resolves(JSON.stringify({
70
- accessToken: oauthToken,
71
- accountLabel: 'testuser'
72
- }));
73
-
74
- const token = await authService.getAccessToken();
75
- expect(token).to.equal(oauthToken);
76
- });
77
-
78
- it('should return undefined when stored data is malformed', async () => {
79
- getPasswordStub.resolves('not-valid-json');
22
+ interface Stubs {
23
+ /** Whether the sign-in of the previous integration had to be discarded. */
24
+ legacyRemoved: boolean;
25
+ /** How long that cleanup takes, to model a slow keychain. */
26
+ legacyDelay: number;
27
+ authState: CopilotAuthState;
28
+ authStateCalls: number;
29
+ configuredPath: string | undefined;
30
+ deleted: number;
31
+ }
32
+
33
+ class TestableCopilotAuthServiceImpl extends CopilotAuthServiceImpl {
34
+
35
+ readonly stubs: Stubs = {
36
+ legacyRemoved: false,
37
+ legacyDelay: 0,
38
+ authState: { isAuthenticated: false },
39
+ authStateCalls: 0,
40
+ configuredPath: undefined,
41
+ deleted: 0
42
+ };
80
43
 
81
- const token = await authService.getAccessToken();
82
- expect(token).to.be.undefined;
44
+ constructor() {
45
+ super();
46
+ const stubs = this.stubs;
47
+ Object.assign(this, {
48
+ logger: new MockLogger(),
49
+ credentialStore: {
50
+ deleteLegacy: async () => {
51
+ await new Promise(resolve => setTimeout(resolve, stubs.legacyDelay));
52
+ return stubs.legacyRemoved;
53
+ }
54
+ },
55
+ cliLocator: { setConfiguredPath: (path: string | undefined) => { stubs.configuredPath = path; } },
56
+ cliAuthProvider: {
57
+ getAuthState: async () => {
58
+ stubs.authStateCalls++;
59
+ return stubs.authState;
60
+ },
61
+ signOut: async () => {
62
+ stubs.deleted++;
63
+ stubs.authState = { isAuthenticated: false };
64
+ },
65
+ waitForLogin: async () => true
66
+ }
83
67
  });
68
+ }
69
+
70
+ /** Runs what `@postConstruct` runs in production. */
71
+ start(): void {
72
+ this.init();
73
+ }
74
+ }
75
+
76
+ describe('CopilotAuthServiceImpl - migration of the previous sign-in', () => {
77
+
78
+ it('should report that a new sign-in is required, even when asked before the cleanup finished', async () => {
79
+ const service = new TestableCopilotAuthServiceImpl();
80
+ service.stubs.legacyRemoved = true;
81
+ // The frontend evaluates the flag once at startup, so a state cached without it loses it.
82
+ service.stubs.legacyDelay = 20;
83
+ service.start();
84
+ expect(await service.getAuthState()).to.deep.include({ isAuthenticated: false, migrationRequired: true });
84
85
  });
85
86
 
86
- describe('getAuthState', () => {
87
-
88
- it('should return not authenticated when no credentials are stored', async () => {
89
- getPasswordStub.resolves(undefined);
90
-
91
- const state = await authService.getAuthState();
92
- expect(state.isAuthenticated).to.be.false;
93
- expect(state.accountLabel).to.be.undefined;
94
- });
95
-
96
- it('should return authenticated with account label when credentials exist', async () => {
97
- getPasswordStub.resolves(JSON.stringify({
98
- accessToken: 'gho_test',
99
- accountLabel: 'testuser',
100
- enterpriseUrl: undefined
101
- }));
102
-
103
- const state = await authService.getAuthState();
104
- expect(state.isAuthenticated).to.be.true;
105
- expect(state.accountLabel).to.equal('testuser');
106
- });
107
-
108
- it('should return enterprise URL when stored', async () => {
109
- getPasswordStub.resolves(JSON.stringify({
110
- accessToken: 'gho_test',
111
- accountLabel: 'testuser',
112
- enterpriseUrl: 'github.mycompany.com'
113
- }));
114
-
115
- const state = await authService.getAuthState();
116
- expect(state.isAuthenticated).to.be.true;
117
- expect(state.enterpriseUrl).to.equal('github.mycompany.com');
118
- });
119
-
120
- it('should clear outdated non-gho_ token and flag migration', async () => {
121
- getPasswordStub.resolves(JSON.stringify({
122
- accessToken: 'ghu_old_token',
123
- accountLabel: 'testuser'
124
- }));
125
-
126
- const state = await authService.getAuthState();
127
- expect(state.isAuthenticated).to.be.false;
128
- expect(state.migrationRequired).to.be.true;
129
- expect((keyStoreService.deletePassword as sinon.SinonStub).calledOnce).to.be.true;
130
- });
131
-
132
- it('should cache the auth state after first retrieval', async () => {
133
- getPasswordStub.resolves(JSON.stringify({
134
- accessToken: 'gho_test',
135
- accountLabel: 'testuser'
136
- }));
137
-
138
- const state1 = await authService.getAuthState();
139
- const state2 = await authService.getAuthState();
140
-
141
- expect(state1).to.equal(state2);
142
- expect(getPasswordStub.calledOnce).to.be.true;
143
- });
87
+ it('should not claim a migration when there was nothing to discard', async () => {
88
+ const service = new TestableCopilotAuthServiceImpl();
89
+ service.start();
90
+ expect(await service.getAuthState()).to.not.have.property('migrationRequired');
144
91
  });
145
92
 
146
- describe('signOut', () => {
147
-
148
- it('should clear cached auth state on sign out', async () => {
149
- getPasswordStub.resolves(JSON.stringify({
150
- accessToken: 'gho_test',
151
- accountLabel: 'testuser'
152
- }));
153
-
154
- const stateBefore = await authService.getAuthState();
155
- expect(stateBefore.isAuthenticated).to.be.true;
156
-
157
- await authService.signOut();
158
-
159
- // After sign out, cached state should be cleared
160
- // getAuthState should now return the new state without hitting keystore cache
161
- const stateAfter = await authService.getAuthState();
162
- expect(stateAfter.isAuthenticated).to.be.false;
163
- });
164
-
165
- it('should fire auth state changed event on sign out', async () => {
166
- const stateChanges: { isAuthenticated: boolean }[] = [];
167
- authService.onAuthStateChanged(state => stateChanges.push(state));
168
-
169
- await authService.signOut();
170
-
171
- expect(stateChanges).to.have.lengthOf(1);
172
- expect(stateChanges[0].isAuthenticated).to.be.false;
173
- });
174
-
175
- it('should delete credentials from keystore on sign out', async () => {
176
- await authService.signOut();
177
- expect((keyStoreService.deletePassword as sinon.SinonStub).calledOnce).to.be.true;
178
- });
179
-
180
- it('should notify client on sign out', async () => {
181
- const client = { onAuthStateChanged: sinon.stub() };
182
- authService.setClient(client);
183
-
184
- await authService.signOut();
93
+ it('should not claim a migration for a user who is signed in', async () => {
94
+ const service = new TestableCopilotAuthServiceImpl();
95
+ service.stubs.legacyRemoved = true;
96
+ service.stubs.authState = { isAuthenticated: true, accountLabel: 'octocat' };
97
+ service.start();
98
+ expect(await service.getAuthState()).to.not.have.property('migrationRequired');
99
+ });
185
100
 
186
- expect(client.onAuthStateChanged.calledOnce).to.be.true;
187
- expect(client.onAuthStateChanged.firstCall.args[0].isAuthenticated).to.be.false;
188
- });
101
+ it('should survive a keychain that cannot be read', async () => {
102
+ const service = new TestableCopilotAuthServiceImpl();
103
+ Object.assign(service, { credentialStore: { deleteLegacy: async () => { throw new Error('keychain unavailable'); } } });
104
+ service.start();
105
+ expect(await service.getAuthState()).to.deep.equal({ isAuthenticated: false });
189
106
  });
190
107
  });
191
108
 
192
- describe('CopilotAuthServiceImpl with custom CopilotOAuthConfig', () => {
193
-
194
- const customConfig: CopilotOAuthConfig = {
195
- clientId: 'CustomClientId',
196
- userAgent: 'MyApp/1.0.0',
197
- keystoreService: 'myapp-copilot',
198
- keystoreAccount: 'myapp-github'
199
- };
109
+ describe('CopilotAuthServiceImpl - state', () => {
200
110
 
201
- let authService: CopilotAuthServiceImpl;
202
- let getPasswordStub: sinon.SinonStub;
203
- let deletePasswordStub: sinon.SinonStub;
111
+ it('should ask the provider once and serve the cached state afterwards', async () => {
112
+ const service = new TestableCopilotAuthServiceImpl();
113
+ service.start();
114
+ await service.getAuthState();
115
+ await service.getAuthState();
116
+ expect(service.stubs.authStateCalls).to.equal(1);
117
+ });
204
118
 
205
- beforeEach(() => {
206
- const container = new Container();
119
+ it('should report the new state after a sign-in', async () => {
120
+ const service = new TestableCopilotAuthServiceImpl();
121
+ service.start();
122
+ await service.getAuthState();
123
+ service.stubs.authState = { isAuthenticated: true, accountLabel: 'octocat' };
207
124
 
208
- container.bind(ILogger).to(MockLogger).inSingletonScope();
125
+ const reported: CopilotAuthState[] = [];
126
+ service.onAuthStateChanged(state => reported.push(state));
127
+ expect(await service.waitForSignIn()).to.be.true;
209
128
 
210
- getPasswordStub = sinon.stub().resolves(undefined);
211
- deletePasswordStub = sinon.stub().resolves(true);
212
- const keyStoreService: KeyStoreService = {
213
- setPassword: sinon.stub().resolves() as KeyStoreService['setPassword'],
214
- getPassword: getPasswordStub as KeyStoreService['getPassword'],
215
- deletePassword: deletePasswordStub as KeyStoreService['deletePassword'],
216
- findPassword: sinon.stub().resolves(undefined) as KeyStoreService['findPassword'],
217
- findCredentials: sinon.stub().resolves([]) as KeyStoreService['findCredentials'],
218
- keys: sinon.stub().resolves([]) as KeyStoreService['keys']
219
- };
129
+ expect(reported).to.deep.equal([{ isAuthenticated: true, accountLabel: 'octocat' }]);
130
+ expect(await service.getAuthState()).to.deep.equal({ isAuthenticated: true, accountLabel: 'octocat' });
131
+ });
220
132
 
221
- container.bind(KeyStoreService).toConstantValue(keyStoreService);
222
- container.bind(CopilotOAuthConfig).toConstantValue(customConfig);
223
- container.bind(CopilotAuthServiceImpl).toSelf().inSingletonScope();
133
+ it('should report the new state after signing out', async () => {
134
+ const service = new TestableCopilotAuthServiceImpl();
135
+ service.stubs.authState = { isAuthenticated: true, accountLabel: 'octocat' };
136
+ service.start();
137
+ await service.getAuthState();
224
138
 
225
- authService = container.get(CopilotAuthServiceImpl);
226
- });
139
+ const reported: CopilotAuthState[] = [];
140
+ service.onAuthStateChanged(state => reported.push(state));
141
+ await service.signOut();
227
142
 
228
- afterEach(() => {
229
- sinon.restore();
143
+ expect(service.stubs.deleted).to.equal(1);
144
+ expect(reported).to.deep.equal([{ isAuthenticated: false }]);
230
145
  });
146
+ });
231
147
 
232
- it('should read credentials from the custom keystore service and account', async () => {
233
- getPasswordStub.resolves(JSON.stringify({
234
- accessToken: 'gho_custom_token',
235
- accountLabel: 'customuser'
236
- }));
237
-
238
- const token = await authService.getAccessToken();
148
+ describe('CopilotAuthServiceImpl - executable path', () => {
239
149
 
240
- expect(token).to.equal('gho_custom_token');
241
- expect(getPasswordStub.calledOnceWith(customConfig.keystoreService, customConfig.keystoreAccount)).to.be.true;
150
+ it('should hand the configured location to the lookup, which cannot read preferences itself', async () => {
151
+ const service = new TestableCopilotAuthServiceImpl();
152
+ service.start();
153
+ await service.setExecutablePath('/opt/copilot/copilot');
154
+ expect(service.stubs.configuredPath).to.equal('/opt/copilot/copilot');
242
155
  });
243
156
 
244
- it('should delete credentials from the custom keystore service and account on sign out', async () => {
245
- await authService.signOut();
246
-
247
- expect(deletePasswordStub.calledOnceWith(customConfig.keystoreService, customConfig.keystoreAccount)).to.be.true;
157
+ it('should hand on that nothing is configured, so that the CLI is searched for', async () => {
158
+ const service = new TestableCopilotAuthServiceImpl();
159
+ service.start();
160
+ await service.setExecutablePath('/opt/copilot/copilot');
161
+ await service.setExecutablePath(undefined);
162
+ expect(service.stubs.configuredPath).to.be.undefined;
248
163
  });
249
164
  });