diff --git a/product.json b/product.json index 3eeae17135a6a..e3c6fbb58e530 100644 --- a/product.json +++ b/product.json @@ -142,7 +142,9 @@ "resolveMergeConflictsCommand": "github.copilot.git.resolveMergeConflicts", "completionsAdvancedSetting": "github.copilot.advanced", "completionsEnablementSetting": "github.copilot.enable", - "nextEditSuggestionsSetting": "github.copilot.nextEditSuggestions.enabled" + "nextEditSuggestionsSetting": "github.copilot.nextEditSuggestions.enabled", + "tokenEntitlementUrl": "https://api.github.com/copilot_internal/v2/token", + "mcpRegistryDataUrl": "https://api.github.com/copilot/mcp_registry" }, "trustedExtensionAuthAccess": { "github": [ diff --git a/src/vs/base/common/defaultAccount.ts b/src/vs/base/common/defaultAccount.ts index dd412d4de0be0..7eb9803f3b528 100644 --- a/src/vs/base/common/defaultAccount.ts +++ b/src/vs/base/common/defaultAccount.ts @@ -3,19 +3,16 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -export interface IDefaultAccount { - readonly sessionId: string; - readonly enterprise: boolean; - readonly access_type_sku?: string; - readonly copilot_plan?: string; - readonly assigned_date?: string; - readonly can_signup_for_limited?: boolean; - readonly chat_enabled?: boolean; - readonly chat_preview_features_enabled?: boolean; - readonly mcp?: boolean; - readonly mcpRegistryUrl?: string; - readonly mcpAccess?: 'allow_all' | 'registry_only'; - readonly analytics_tracking_id?: string; +export interface IQuotaSnapshotData { + readonly entitlement: number; + readonly overage_count: number; + readonly overage_permitted: boolean; + readonly percent_remaining: number; + readonly remaining: number; + readonly unlimited: boolean; +} + +export interface ILegacyQuotaSnapshotData { readonly limited_user_quotas?: { readonly chat: number; readonly completions: number; @@ -24,6 +21,44 @@ export interface IDefaultAccount { readonly chat: number; readonly completions: number; }; - readonly limited_user_reset_date?: string; +} + +export interface IEntitlementsData extends ILegacyQuotaSnapshotData { + readonly access_type_sku: string; + readonly assigned_date: string; + readonly can_signup_for_limited: boolean; + readonly chat_enabled: boolean; + readonly copilot_plan: string; + readonly organization_login_list: string[]; + readonly analytics_tracking_id: string; + readonly limited_user_reset_date?: string; // for Copilot Free + readonly quota_reset_date?: string; // for all other Copilot SKUs + readonly quota_reset_date_utc?: string; // for all other Copilot SKUs (includes time) + readonly quota_snapshots?: { + chat?: IQuotaSnapshotData; + completions?: IQuotaSnapshotData; + premium_interactions?: IQuotaSnapshotData; + }; +} + +export interface IPolicyData { + readonly mcp?: boolean; + readonly chat_preview_features_enabled?: boolean; readonly chat_agent_enabled?: boolean; + readonly mcpRegistryUrl?: string; + readonly mcpAccess?: 'allow_all' | 'registry_only'; +} + +export interface IDefaultAccountAuthenticationProvider { + readonly id: string; + readonly name: string; + readonly enterprise: boolean; +} + +export interface IDefaultAccount { + readonly authenticationProvider: IDefaultAccountAuthenticationProvider; + readonly sessionId: string; + readonly enterprise: boolean; + readonly entitlementsData?: IEntitlementsData | null; + readonly policyData?: IPolicyData; } diff --git a/src/vs/base/common/product.ts b/src/vs/base/common/product.ts index f8d394dfafead..7820be2a1a4e2 100644 --- a/src/vs/base/common/product.ts +++ b/src/vs/base/common/product.ts @@ -208,7 +208,6 @@ export interface IProductConfiguration { readonly msftInternalDomains?: string[]; readonly linkProtectionTrustedDomains?: readonly string[]; - readonly defaultAccount?: IDefaultAccountConfig; readonly authClientIdMetadataUrl?: string; readonly 'configurationSync.store'?: ConfigurationSyncStore; @@ -231,20 +230,6 @@ export interface IProductConfiguration { readonly extensionConfigurationPolicy?: IStringDictionary; } -export interface IDefaultAccountConfig { - readonly preferredExtensions: string[]; - readonly authenticationProvider: { - readonly id: string; - readonly enterpriseProviderId: string; - readonly enterpriseProviderConfig: string; - readonly enterpriseProviderUriSetting: string; - readonly scopes: string[][]; - }; - readonly tokenEntitlementUrl: string; - readonly chatEntitlementUrl: string; - readonly mcpRegistryDataUrl: string; -} - export interface ITunnelApplicationConfig { authenticationProviders: IStringDictionary<{ scopes: string[] }>; editorWebUrl: string; @@ -377,6 +362,8 @@ export interface IDefaultChatAgent { readonly entitlementUrl: string; readonly entitlementSignupLimitedUrl: string; + readonly tokenEntitlementUrl: string; + readonly mcpRegistryDataUrl: string; readonly chatQuotaExceededContext: string; readonly completionsQuotaExceededContext: string; diff --git a/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts b/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts index 6334e8bdfe195..f803edeb37242 100644 --- a/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts +++ b/src/vs/editor/browser/controller/editContext/native/nativeEditContext.ts @@ -5,10 +5,11 @@ import './nativeEditContext.css'; import { isFirefox } from '../../../../../base/browser/browser.js'; -import { addDisposableListener, getActiveElement, getWindow, getWindowId } from '../../../../../base/browser/dom.js'; +import { addDisposableListener, getActiveElement, getWindow, getWindowId, scheduleAtNextAnimationFrame } from '../../../../../base/browser/dom.js'; import { FastDomNode } from '../../../../../base/browser/fastDomNode.js'; import { StandardKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import { KeyCode } from '../../../../../base/common/keyCodes.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { EditorOption } from '../../../../common/config/editorOptions.js'; import { EndOfLinePreference, IModelDeltaDecoration } from '../../../../common/model.js'; @@ -68,6 +69,7 @@ export class NativeEditContext extends AbstractEditContext { private _targetWindowId: number = -1; private _scrollTop: number = 0; private _scrollLeft: number = 0; + private _selectionAndControlBoundsUpdateDisposable: IDisposable | undefined; private readonly _focusTracker: FocusTracker; @@ -233,6 +235,8 @@ export class NativeEditContext extends AbstractEditContext { this.domNode.domNode.blur(); this.domNode.domNode.remove(); this._imeTextArea.domNode.remove(); + this._selectionAndControlBoundsUpdateDisposable?.dispose(); + this._selectionAndControlBoundsUpdateDisposable = undefined; super.dispose(); } @@ -505,7 +509,19 @@ export class NativeEditContext extends AbstractEditContext { } } - private _updateSelectionAndControlBoundsAfterRender() { + private _updateSelectionAndControlBoundsAfterRender(): void { + if (this._selectionAndControlBoundsUpdateDisposable) { + return; + } + // Schedule this work after render so we avoid triggering a layout while still painting. + const targetWindow = getWindow(this.domNode.domNode); + this._selectionAndControlBoundsUpdateDisposable = scheduleAtNextAnimationFrame(targetWindow, () => { + this._selectionAndControlBoundsUpdateDisposable = undefined; + this._applySelectionAndControlBounds(); + }); + } + + private _applySelectionAndControlBounds(): void { const options = this._context.configuration.options; const contentLeft = options.get(EditorOption.layoutInfo).contentLeft; diff --git a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts index b1eab383d05c2..53988fb113b32 100644 --- a/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts +++ b/src/vs/editor/browser/controller/editContext/textArea/textAreaEditContext.ts @@ -6,6 +6,7 @@ import './textAreaEditContext.css'; import * as nls from '../../../../../nls.js'; import * as browser from '../../../../../base/browser/browser.js'; +import { scheduleAtNextAnimationFrame, getWindow } from '../../../../../base/browser/dom.js'; import { FastDomNode, createFastDomNode } from '../../../../../base/browser/fastDomNode.js'; import { IKeyboardEvent } from '../../../../../base/browser/keyboardEvent.js'; import * as platform from '../../../../../base/common/platform.js'; @@ -31,6 +32,7 @@ import { MOUSE_CURSOR_TEXT_CSS_CLASS_NAME } from '../../../../../base/browser/ui import { TokenizationRegistry } from '../../../../common/languages.js'; import { ColorId, ITokenPresentation } from '../../../../common/encodedTokenAttributes.js'; import { Color } from '../../../../../base/common/color.js'; +import { IDisposable } from '../../../../../base/common/lifecycle.js'; import { IME } from '../../../../../base/common/ime.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; @@ -139,6 +141,7 @@ export class TextAreaEditContext extends AbstractEditContext { * This is useful for hit-testing and determining the mouse position. */ private _lastRenderPosition: Position | null; + private _scheduledRender: IDisposable | null = null; public readonly textArea: FastDomNode; public readonly textAreaCover: FastDomNode; @@ -459,6 +462,8 @@ export class TextAreaEditContext extends AbstractEditContext { } public override dispose(): void { + this._scheduledRender?.dispose(); + this._scheduledRender = null; super.dispose(); this.textArea.domNode.remove(); this.textAreaCover.domNode.remove(); @@ -682,7 +687,20 @@ export class TextAreaEditContext extends AbstractEditContext { public render(ctx: RestrictedRenderingContext): void { this._textAreaInput.writeNativeTextAreaContent('render'); - this._render(); + this._scheduleRender(); + } + + // Delay expensive DOM updates until the next animation frame to reduce reflow pressure. + private _scheduleRender(): void { + if (this._scheduledRender) { + return; + } + + const targetWindow = getWindow(this.textArea.domNode); + this._scheduledRender = scheduleAtNextAnimationFrame(targetWindow, () => { + this._scheduledRender = null; + this._render(); + }); } private _render(): void { diff --git a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts index 222e76c5e513c..eee28998bb672 100644 --- a/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts +++ b/src/vs/editor/contrib/inlineCompletions/browser/model/inlineCompletionsModel.ts @@ -1279,8 +1279,8 @@ export function isSuggestionInViewport(editor: ICodeEditor, suggestion: InlineSu } function skuFromAccount(account: IDefaultAccount | null): InlineSuggestSku | undefined { - if (account?.access_type_sku && account?.copilot_plan) { - return { type: account.access_type_sku, plan: account.copilot_plan }; + if (account?.entitlementsData?.access_type_sku && account?.entitlementsData?.copilot_plan) { + return { type: account.entitlementsData.access_type_sku, plan: account.entitlementsData.copilot_plan }; } return undefined; } diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/suggestWidgetModel.test.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/suggestWidgetModel.test.ts index f9b51241aa3cc..6743ca65a9cb9 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/suggestWidgetModel.test.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/suggestWidgetModel.test.ts @@ -171,7 +171,7 @@ async function withAsyncTestCodeEditorAndInlineCompletionsModel( [IDefaultAccountService, new class extends mock() { override onDidChangeDefaultAccount = Event.None; override getDefaultAccount = async () => null; - override setDefaultAccount = () => { }; + override setDefaultAccountProvider = () => { }; }], ); diff --git a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts index bbd453dcaf5aa..cb1adfefdbe38 100644 --- a/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts +++ b/src/vs/editor/contrib/inlineCompletions/test/browser/utils.ts @@ -267,7 +267,10 @@ export async function withAsyncTestCodeEditorAndInlineCompletionsModel( _serviceBrand: undefined, onDidChangeDefaultAccount: Event.None, getDefaultAccount: async () => null, - setDefaultAccount: () => { }, + setDefaultAccountProvider: () => { }, + getDefaultAccountAuthenticationProvider: () => { return { id: 'mockProvider', name: 'Mock Provider', enterprise: false }; }, + refresh: async () => { return null; }, + signIn: async () => { return null; }, }); const d = languageFeaturesService.inlineCompletionsProvider.register({ pattern: '**' }, options.provider); diff --git a/src/vs/editor/standalone/browser/standaloneServices.ts b/src/vs/editor/standalone/browser/standaloneServices.ts index 44e4b54d1b74f..196514a540e7a 100644 --- a/src/vs/editor/standalone/browser/standaloneServices.ts +++ b/src/vs/editor/standalone/browser/standaloneServices.ts @@ -99,7 +99,7 @@ import { IDataChannelService, NullDataChannelService } from '../../../platform/d import { IWebWorkerService } from '../../../platform/webWorker/browser/webWorkerService.js'; import { StandaloneWebWorkerService } from './services/standaloneWebWorkerService.js'; import { IDefaultAccountService } from '../../../platform/defaultAccount/common/defaultAccount.js'; -import { IDefaultAccount } from '../../../base/common/defaultAccount.js'; +import { IDefaultAccount, IDefaultAccountAuthenticationProvider } from '../../../base/common/defaultAccount.js'; class SimpleModel implements IResolvedTextEditorModel { @@ -1118,9 +1118,21 @@ class StandaloneDefaultAccountService implements IDefaultAccountService { return null; } - setDefaultAccount(account: IDefaultAccount | null): void { + setDefaultAccountProvider(): void { // no-op } + + async refresh(): Promise { + return null; + } + + getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { + return { id: 'default', name: 'Default', enterprise: false }; + } + + async signIn(): Promise { + return null; + } } export interface IEditorOverrideServices { diff --git a/src/vs/platform/defaultAccount/common/defaultAccount.ts b/src/vs/platform/defaultAccount/common/defaultAccount.ts index c9db5b2255566..d3bee567a7946 100644 --- a/src/vs/platform/defaultAccount/common/defaultAccount.ts +++ b/src/vs/platform/defaultAccount/common/defaultAccount.ts @@ -5,16 +5,24 @@ import { createDecorator } from '../../instantiation/common/instantiation.js'; import { Event } from '../../../base/common/event.js'; -import { IDefaultAccount } from '../../../base/common/defaultAccount.js'; +import { IDefaultAccount, IDefaultAccountAuthenticationProvider } from '../../../base/common/defaultAccount.js'; + +export interface IDefaultAccountProvider { + readonly defaultAccount: IDefaultAccount | null; + readonly onDidChangeDefaultAccount: Event; + getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; + refresh(): Promise; + signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise; +} export const IDefaultAccountService = createDecorator('defaultAccountService'); export interface IDefaultAccountService { - readonly _serviceBrand: undefined; - readonly onDidChangeDefaultAccount: Event; - getDefaultAccount(): Promise; - setDefaultAccount(account: IDefaultAccount | null): void; + getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider; + setDefaultAccountProvider(provider: IDefaultAccountProvider): void; + refresh(): Promise; + signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise; } diff --git a/src/vs/platform/product/common/product.ts b/src/vs/platform/product/common/product.ts index 6f093e9be9463..3af87ba6493ad 100644 --- a/src/vs/platform/product/common/product.ts +++ b/src/vs/platform/product/common/product.ts @@ -70,7 +70,22 @@ else { reportIssueUrl: 'https://github.com/microsoft/vscode/issues/new', licenseName: 'MIT', licenseUrl: 'https://github.com/microsoft/vscode/blob/main/LICENSE.txt', - serverLicenseUrl: 'https://github.com/microsoft/vscode/blob/main/LICENSE.txt' + serverLicenseUrl: 'https://github.com/microsoft/vscode/blob/main/LICENSE.txt', + defaultChatAgent: { + extensionId: 'GitHub.copilot', + chatExtensionId: 'GitHub.copilot-chat', + provider: { + default: { + id: 'github', + name: 'GitHub', + }, + enterprise: { + id: 'github-enterprise', + name: 'GitHub Enterprise', + } + }, + providerScopes: [] + } }); } } diff --git a/src/vs/workbench/browser/web.main.ts b/src/vs/workbench/browser/web.main.ts index 55929182a6bc5..1586cb4ca824b 100644 --- a/src/vs/workbench/browser/web.main.ts +++ b/src/vs/workbench/browser/web.main.ts @@ -349,7 +349,7 @@ export class BrowserMain extends Disposable { this._register(RemoteFileSystemProviderClient.register(remoteAgentService, fileService, logService)); // Default Account - const defaultAccountService = this._register(new DefaultAccountService()); + const defaultAccountService = this._register(new DefaultAccountService(productService)); serviceCollection.set(IDefaultAccountService, defaultAccountService); // Policies diff --git a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts index 91a886107c54c..21001e6947f31 100644 --- a/src/vs/workbench/contrib/chat/browser/chat.contribution.ts +++ b/src/vs/workbench/contrib/chat/browser/chat.contribution.ts @@ -287,7 +287,7 @@ configurationRegistry.registerConfiguration({ name: 'ChatToolsAutoApprove', category: PolicyCategory.InteractiveSession, minimumVersion: '1.99', - value: (account) => account.chat_preview_features_enabled === false ? false : undefined, + value: (account) => account.policyData?.chat_preview_features_enabled === false ? false : undefined, localization: { description: { key: 'autoApprove2.description', @@ -445,10 +445,10 @@ configurationRegistry.registerConfiguration({ category: PolicyCategory.InteractiveSession, minimumVersion: '1.99', value: (account) => { - if (account.mcp === false) { + if (account.policyData?.mcp === false) { return McpAccessValue.None; } - if (account.mcpAccess === 'registry_only') { + if (account.policyData?.mcpAccess === 'registry_only') { return McpAccessValue.Registry; } return undefined; @@ -559,7 +559,7 @@ configurationRegistry.registerConfiguration({ name: 'ChatAgentMode', category: PolicyCategory.InteractiveSession, minimumVersion: '1.99', - value: (account) => account.chat_agent_enabled === false ? false : undefined, + value: (account) => account.policyData?.chat_agent_enabled === false ? false : undefined, localization: { description: { key: 'chat.agent.enabled.description', @@ -619,7 +619,7 @@ configurationRegistry.registerConfiguration({ name: 'McpGalleryServiceUrl', category: PolicyCategory.InteractiveSession, minimumVersion: '1.101', - value: (account) => account.mcpRegistryUrl, + value: (account) => account.policyData?.mcpRegistryUrl, localization: { description: { key: 'mcp.gallery.serviceUrl', diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts index 1d3485df87bf5..73adc06cf309f 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupContributions.ts @@ -371,7 +371,7 @@ export class ChatSetupContribution extends Disposable implements IWorkbenchContr if (focus) { windowFocusListener.clear(); - const entitlements = await requests.forceResolveEntitlement(undefined); + const entitlements = await requests.forceResolveEntitlement(); if (entitlements?.entitlement && isProUser(entitlements?.entitlement)) { refreshTokens(commandService); } diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupController.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupController.ts index 39b4ccd15f9e8..0379543f99679 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupController.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupController.ts @@ -9,7 +9,7 @@ import { Emitter } from '../../../../../base/common/event.js'; import { Disposable } from '../../../../../base/common/lifecycle.js'; import Severity from '../../../../../base/common/severity.js'; import { StopWatch } from '../../../../../base/common/stopwatch.js'; -import { isObject } from '../../../../../base/common/types.js'; +import { isObject, isUndefined } from '../../../../../base/common/types.js'; import { localize } from '../../../../../nls.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; import { ConfigurationTarget, IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; @@ -23,13 +23,14 @@ import { IQuickInputService } from '../../../../../platform/quickinput/common/qu import { Registry } from '../../../../../platform/registry/common/platform.js'; import { ITelemetryService } from '../../../../../platform/telemetry/common/telemetry.js'; import { IActivityService, ProgressBadge } from '../../../../services/activity/common/activity.js'; -import { AuthenticationSession, IAuthenticationService } from '../../../../services/authentication/common/authentication.js'; import { ILifecycleService } from '../../../../services/lifecycle/common/lifecycle.js'; import { IExtensionsWorkbenchService } from '../../../extensions/common/extensions.js'; import { ChatEntitlement, ChatEntitlementContext, ChatEntitlementRequests, isProUser } from '../../../../services/chat/common/chatEntitlementService.js'; import { CHAT_OPEN_ACTION_ID } from '../actions/chatActions.js'; import { ChatViewId, ChatViewContainerId } from '../chat.js'; import { ChatSetupAnonymous, ChatSetupStep, ChatSetupResultValue, InstallChatEvent, InstallChatClassification, refreshTokens } from './chatSetup.js'; +import { IDefaultAccount } from '../../../../../base/common/defaultAccount.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; const defaultChat = { chatExtensionId: product.defaultChatAgent?.chatExtensionId ?? '', @@ -58,7 +59,6 @@ export class ChatSetupController extends Disposable { private readonly context: ChatEntitlementContext, private readonly requests: ChatEntitlementRequests, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IAuthenticationService private readonly authenticationService: IAuthenticationService, @IExtensionsWorkbenchService private readonly extensionsWorkbenchService: IExtensionsWorkbenchService, @IProductService private readonly productService: IProductService, @ILogService private readonly logService: ILogService, @@ -69,6 +69,7 @@ export class ChatSetupController extends Disposable { @IConfigurationService private readonly configurationService: IConfigurationService, @ILifecycleService private readonly lifecycleService: ILifecycleService, @IQuickInputService private readonly quickInputService: IQuickInputService, + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, ) { super(); @@ -111,8 +112,6 @@ export class ChatSetupController extends Disposable { let success: ChatSetupResultValue = false; try { - const providerId = ChatEntitlementRequests.providerId(this.configurationService); - let session: AuthenticationSession | undefined; let entitlement: ChatEntitlement | undefined; let signIn: boolean; @@ -131,7 +130,7 @@ export class ChatSetupController extends Disposable { if (signIn) { this.setStep(ChatSetupStep.SigningIn); const result = await this.signIn(options); - if (!result.session) { + if (!result.defaultAccount) { this.doInstall(); // still install the extension in the background to remind the user to sign-in eventually const provider = options.useSocialProvider ?? (options.useEnterpriseProvider ? defaultChat.provider.enterprise.id : defaultChat.provider.default.id); @@ -139,13 +138,12 @@ export class ChatSetupController extends Disposable { return undefined; // treat as cancelled because signing in already triggers an error dialog } - session = result.session; entitlement = result.entitlement; } // Await Install this.setStep(ChatSetupStep.Installing); - success = await this.install(session, entitlement ?? this.context.state.entitlement, providerId, watch, options); + success = await this.install(entitlement ?? this.context.state.entitlement, watch, options); } finally { this.setStep(ChatSetupStep.Initial); this.context.resume(); @@ -154,19 +152,19 @@ export class ChatSetupController extends Disposable { return success; } - private async signIn(options: IChatSetupControllerOptions): Promise<{ session: AuthenticationSession | undefined; entitlement: ChatEntitlement | undefined }> { - let session: AuthenticationSession | undefined; + private async signIn(options: IChatSetupControllerOptions): Promise<{ defaultAccount: IDefaultAccount | undefined; entitlement: ChatEntitlement | undefined }> { let entitlements; + let defaultAccount; try { - ({ session, entitlements } = await this.requests.signIn(options)); + ({ defaultAccount, entitlements } = await this.requests.signIn(options)); } catch (e) { this.logService.error(`[chat setup] signIn: error ${e}`); } - if (!session && !this.lifecycleService.willShutdown) { + if (!defaultAccount && !this.lifecycleService.willShutdown) { const { confirmed } = await this.dialogService.confirm({ type: Severity.Error, - message: localize('unknownSignInError', "Failed to sign in to {0}. Would you like to try again?", ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.provider.enterprise.id ? defaultChat.provider.enterprise.name : defaultChat.provider.default.name), + message: localize('unknownSignInError', "Failed to sign in to {0}. Would you like to try again?", this.defaultAccountService.getDefaultAccountAuthenticationProvider().name), detail: localize('unknownSignInErrorDetail', "You must be signed in to use AI features."), primaryButton: localize('retry', "Retry") }); @@ -176,10 +174,10 @@ export class ChatSetupController extends Disposable { } } - return { session, entitlement: entitlements?.entitlement }; + return { defaultAccount, entitlement: entitlements?.entitlement }; } - private async install(session: AuthenticationSession | undefined, entitlement: ChatEntitlement, providerId: string, watch: StopWatch, options: IChatSetupControllerOptions): Promise { + private async install(entitlement: ChatEntitlement, watch: StopWatch, options: IChatSetupControllerOptions): Promise { const wasRunning = this.context.state.installed && !this.context.state.disabled; let signUpResult: boolean | { errorCode: number } | undefined = undefined; @@ -190,7 +188,6 @@ export class ChatSetupController extends Disposable { provider = options.useSocialProvider ?? (options.useEnterpriseProvider ? defaultChat.provider.enterprise.id : defaultChat.provider.default.id); } - let sessions = session ? [session] : undefined; try { if ( !options.forceAnonymous && // User is not asking for anonymous access @@ -198,23 +195,13 @@ export class ChatSetupController extends Disposable { !isProUser(entitlement) && // User is not signed up for a Copilot subscription entitlement !== ChatEntitlement.Unavailable // User is eligible for Copilot Free ) { - if (!sessions) { - try { - // Consider all sessions for the provider to be suitable for signing up - const existingSessions = await this.authenticationService.getSessions(providerId); - sessions = existingSessions.length > 0 ? [...existingSessions] : undefined; - } catch (error) { - // ignore - errors can throw if a provider is not registered - } + signUpResult = await this.requests.signUpFree(); - if (!sessions || sessions.length === 0) { - this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNoSession', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider }); - return false; // unexpected - } + if (isUndefined(signUpResult)) { + this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedNoSession', installDuration: watch.elapsed(), signUpErrorCode: undefined, provider }); + return false; // unexpected } - signUpResult = await this.requests.signUpFree(sessions); - if (typeof signUpResult !== 'boolean' /* error */) { this.telemetryService.publicLog2('commandCenter.chatInstall', { installResult: 'failedSignUp', installDuration: watch.elapsed(), signUpErrorCode: signUpResult.errorCode, provider }); } diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts index 429a6d99bf052..e417344be53b5 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupProviders.ts @@ -14,7 +14,6 @@ import { Lazy } from '../../../../../base/common/lazy.js'; import { Disposable, DisposableStore, IDisposable } from '../../../../../base/common/lifecycle.js'; import { URI } from '../../../../../base/common/uri.js'; import { localize, localize2 } from '../../../../../nls.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { ContextKeyExpr } from '../../../../../platform/contextkey/common/contextkey.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { ILogService } from '../../../../../platform/log/common/log.js'; @@ -25,7 +24,7 @@ import { IWorkbenchEnvironmentService } from '../../../../services/environment/c import { nullExtensionDescription } from '../../../../services/extensions/common/extensions.js'; import { CountTokensCallback, ILanguageModelToolsService, IPreparedToolInvocation, IToolData, IToolImpl, IToolInvocation, IToolResult, ToolDataSource, ToolProgress } from '../../common/tools/languageModelToolsService.js'; import { IChatAgentImplementation, IChatAgentRequest, IChatAgentResult, IChatAgentService } from '../../common/participants/chatAgents.js'; -import { ChatEntitlement, ChatEntitlementContext, ChatEntitlementRequests, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; +import { ChatEntitlement, ChatEntitlementContext, IChatEntitlementService } from '../../../../services/chat/common/chatEntitlementService.js'; import { ChatModel, ChatRequestModel, IChatRequestModel, IChatRequestVariableData } from '../../common/model/chatModel.js'; import { ChatMode } from '../../common/chatModes.js'; import { ChatRequestAgentPart, ChatRequestToolPart } from '../../common/requestParser/chatParserTypes.js'; @@ -52,6 +51,7 @@ import { CommandsRegistry } from '../../../../../platform/commands/common/comman import { IOutputService } from '../../../../services/output/common/output.js'; import { ITextModelService } from '../../../../../editor/common/services/resolverService.js'; import { IWorkbenchIssueService } from '../../../issue/common/issue.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; const defaultChat = { extensionId: product.defaultChatAgent?.extensionId ?? '', @@ -181,7 +181,6 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { private readonly location: ChatAgentLocation, @IInstantiationService private readonly instantiationService: IInstantiationService, @ILogService private readonly logService: ILogService, - @IConfigurationService private readonly configurationService: IConfigurationService, @ITelemetryService private readonly telemetryService: ITelemetryService, @IWorkbenchEnvironmentService private readonly environmentService: IWorkbenchEnvironmentService, @IWorkspaceTrustManagementService private readonly workspaceTrustManagementService: IWorkspaceTrustManagementService, @@ -262,12 +261,13 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { const chatWidgetService = accessor.get(IChatWidgetService); const chatAgentService = accessor.get(IChatAgentService); const languageModelToolsService = accessor.get(ILanguageModelToolsService); + const defaultAccountService = accessor.get(IDefaultAccountService); - return this.doInvoke(request, part => progress([part]), chatService, languageModelsService, chatWidgetService, chatAgentService, languageModelToolsService); + return this.doInvoke(request, part => progress([part]), chatService, languageModelsService, chatWidgetService, chatAgentService, languageModelToolsService, defaultAccountService); }); } - private async doInvoke(request: IChatAgentRequest, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatWidgetService: IChatWidgetService, chatAgentService: IChatAgentService, languageModelToolsService: ILanguageModelToolsService): Promise { + private async doInvoke(request: IChatAgentRequest, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatWidgetService: IChatWidgetService, chatAgentService: IChatAgentService, languageModelToolsService: ILanguageModelToolsService, defaultAccountService: IDefaultAccountService): Promise { if ( !this.context.state.installed || // Extension not installed: run setup to install this.context.state.disabled || // Extension disabled: run setup to enable @@ -278,7 +278,7 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { !this.chatEntitlementService.anonymous // unless anonymous access is enabled ) ) { - return this.doInvokeWithSetup(request, progress, chatService, languageModelsService, chatWidgetService, chatAgentService, languageModelToolsService); + return this.doInvokeWithSetup(request, progress, chatService, languageModelsService, chatWidgetService, chatAgentService, languageModelToolsService, defaultAccountService); } return this.doInvokeWithoutSetup(request, progress, chatService, languageModelsService, chatWidgetService, chatAgentService, languageModelToolsService); @@ -510,7 +510,7 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { } } - private async doInvokeWithSetup(request: IChatAgentRequest, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatWidgetService: IChatWidgetService, chatAgentService: IChatAgentService, languageModelToolsService: ILanguageModelToolsService): Promise { + private async doInvokeWithSetup(request: IChatAgentRequest, progress: (part: IChatProgress) => void, chatService: IChatService, languageModelsService: ILanguageModelsService, chatWidgetService: IChatWidgetService, chatAgentService: IChatAgentService, languageModelToolsService: ILanguageModelToolsService, defaultAccountService: IDefaultAccountService): Promise { this.telemetryService.publicLog2('workbenchActionExecuted', { id: CHAT_SETUP_ACTION_ID, from: 'chat' }); const widget = chatWidgetService.getWidgetBySessionResource(request.sessionResource); @@ -521,7 +521,7 @@ export class SetupAgent extends Disposable implements IChatAgentImplementation { case ChatSetupStep.SigningIn: progress({ kind: 'progressMessage', - content: new MarkdownString(localize('setupChatSignIn2', "Signing in to {0}...", ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.provider.enterprise.id ? defaultChat.provider.enterprise.name : defaultChat.provider.default.name)), + content: new MarkdownString(localize('setupChatSignIn2', "Signing in to {0}...", defaultAccountService.getDefaultAccountAuthenticationProvider().name)), }); break; case ChatSetupStep.Installing: diff --git a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts index e03d3616ea2d8..4c51c01f5ad0e 100644 --- a/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts +++ b/src/vs/workbench/contrib/chat/browser/chatSetup/chatSetupRunner.ts @@ -16,7 +16,6 @@ import { DisposableStore } from '../../../../../base/common/lifecycle.js'; import { IMarkdownRendererService } from '../../../../../platform/markdown/browser/markdownRenderer.js'; import { localize } from '../../../../../nls.js'; import { ICommandService } from '../../../../../platform/commands/common/commands.js'; -import { IConfigurationService } from '../../../../../platform/configuration/common/configuration.js'; import { createWorkbenchDialogOptions } from '../../../../../platform/dialogs/browser/dialog.js'; import { IInstantiationService } from '../../../../../platform/instantiation/common/instantiation.js'; import { IKeybindingService } from '../../../../../platform/keybinding/common/keybinding.js'; @@ -26,10 +25,11 @@ import product from '../../../../../platform/product/common/product.js'; import { ITelemetryService, TelemetryLevel } from '../../../../../platform/telemetry/common/telemetry.js'; import { IWorkspaceTrustRequestService } from '../../../../../platform/workspace/common/workspaceTrust.js'; import { IWorkbenchLayoutService } from '../../../../services/layout/browser/layoutService.js'; -import { ChatEntitlement, ChatEntitlementContext, ChatEntitlementRequests, ChatEntitlementService, IChatEntitlementService, isProUser } from '../../../../services/chat/common/chatEntitlementService.js'; +import { ChatEntitlement, ChatEntitlementContext, ChatEntitlementService, IChatEntitlementService, isProUser } from '../../../../services/chat/common/chatEntitlementService.js'; import { IChatWidgetService } from '../chat.js'; import { ChatSetupController } from './chatSetupController.js'; import { IChatSetupResult, ChatSetupAnonymous, InstallChatEvent, InstallChatClassification, ChatSetupStrategy, ChatSetupResultValue } from './chatSetup.js'; +import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; const defaultChat = { publicCodeMatchesUrl: product.defaultChatAgent?.publicCodeMatchesUrl ?? '', @@ -48,7 +48,7 @@ export class ChatSetup { let instance = ChatSetup.instance; if (!instance) { instance = ChatSetup.instance = instantiationService.invokeFunction(accessor => { - return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IConfigurationService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService)); + return new ChatSetup(context, controller, accessor.get(ITelemetryService), accessor.get(IWorkbenchLayoutService), accessor.get(IKeybindingService), accessor.get(IChatEntitlementService) as ChatEntitlementService, accessor.get(ILogService), accessor.get(IChatWidgetService), accessor.get(IWorkspaceTrustRequestService), accessor.get(IMarkdownRendererService), accessor.get(IDefaultAccountService)); }); } @@ -67,10 +67,10 @@ export class ChatSetup { @IKeybindingService private readonly keybindingService: IKeybindingService, @IChatEntitlementService private readonly chatEntitlementService: ChatEntitlementService, @ILogService private readonly logService: ILogService, - @IConfigurationService private readonly configurationService: IConfigurationService, @IChatWidgetService private readonly widgetService: IChatWidgetService, @IWorkspaceTrustRequestService private readonly workspaceTrustRequestService: IWorkspaceTrustRequestService, @IMarkdownRendererService private readonly markdownRendererService: IMarkdownRendererService, + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, ) { } skipDialog(): void { @@ -116,7 +116,7 @@ export class ChatSetup { setupStrategy = await this.showDialog(options); } - if (setupStrategy === ChatSetupStrategy.DefaultSetup && ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.provider.enterprise.id) { + if (setupStrategy === ChatSetupStrategy.DefaultSetup && this.defaultAccountService.getDefaultAccountAuthenticationProvider().enterprise) { setupStrategy = ChatSetupStrategy.SetupWithEnterpriseProvider; // users with a configured provider go through provider setup } @@ -200,7 +200,7 @@ export class ChatSetup { const googleProviderButton: ContinueWithButton = [localize('continueWith', "Continue with {0}", defaultChat.provider.google.name), ChatSetupStrategy.SetupWithGoogleProvider, styleButton('continue-button', 'google')]; const appleProviderButton: ContinueWithButton = [localize('continueWith', "Continue with {0}", defaultChat.provider.apple.name), ChatSetupStrategy.SetupWithAppleProvider, styleButton('continue-button', 'apple')]; - if (ChatEntitlementRequests.providerId(this.configurationService) !== defaultChat.provider.enterprise.id) { + if (!this.defaultAccountService.getDefaultAccountAuthenticationProvider().enterprise) { buttons = coalesce([ defaultProviderButton, googleProviderButton, diff --git a/src/vs/workbench/contrib/chat/common/languageModels.ts b/src/vs/workbench/contrib/chat/common/languageModels.ts index 6127745702a8e..a612296c3d61e 100644 --- a/src/vs/workbench/contrib/chat/common/languageModels.ts +++ b/src/vs/workbench/contrib/chat/common/languageModels.ts @@ -222,15 +222,13 @@ export interface ILanguageModelChatResponse { export interface ILanguageModelChatProvider { readonly onDidChange: Event; provideLanguageModelChatInfo(options: ILanguageModelChatInfoOptions, token: CancellationToken): Promise; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sendChatRequest(modelId: string, messages: IChatMessage[], from: ExtensionIdentifier, options: { [name: string]: any }, token: CancellationToken): Promise; + sendChatRequest(modelId: string, messages: IChatMessage[], from: ExtensionIdentifier, options: { [name: string]: unknown }, token: CancellationToken): Promise; provideTokenCount(modelId: string, message: string | IChatMessage, token: CancellationToken): Promise; } export interface ILanguageModelChat { metadata: ILanguageModelChatMetadata; - // eslint-disable-next-line @typescript-eslint/no-explicit-any - sendChatRequest(messages: IChatMessage[], from: ExtensionIdentifier, options: { [name: string]: any }, token: CancellationToken): Promise; + sendChatRequest(messages: IChatMessage[], from: ExtensionIdentifier, options: { [name: string]: unknown }, token: CancellationToken): Promise; provideTokenCount(message: string | IChatMessage, token: CancellationToken): Promise; } diff --git a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts index e24a22e080245..dd32cdedb95eb 100644 --- a/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts +++ b/src/vs/workbench/contrib/chat/test/common/promptSyntax/service/promptsService.test.ts @@ -1301,265 +1301,7 @@ suite('PromptsService', () => { }); }); - suite('listPromptFiles - skills', () => { - teardown(() => { - sinon.restore(); - }); - - test('should list skill files from workspace', async () => { - testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); - testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); - - const rootFolderName = 'list-skills-workspace'; - const rootFolder = `/${rootFolderName}`; - const rootFolderUri = URI.file(rootFolder); - - workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - - await mockFiles(fileService, [ - { - path: `${rootFolder}/.github/skills/skill1/SKILL.md`, - contents: [ - '---', - 'name: "Skill 1"', - 'description: "First skill"', - '---', - 'Skill 1 content', - ], - }, - { - path: `${rootFolder}/.claude/skills/skill2/SKILL.md`, - contents: [ - '---', - 'name: "Skill 2"', - 'description: "Second skill"', - '---', - 'Skill 2 content', - ], - }, - ]); - - const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); - - assert.strictEqual(result.length, 2, 'Should find 2 skills'); - - const skill1 = result.find(s => s.uri.path.includes('skill1')); - assert.ok(skill1, 'Should find skill1'); - assert.strictEqual(skill1.type, PromptsType.skill); - assert.strictEqual(skill1.storage, PromptsStorage.local); - - const skill2 = result.find(s => s.uri.path.includes('skill2')); - assert.ok(skill2, 'Should find skill2'); - assert.strictEqual(skill2.type, PromptsType.skill); - assert.strictEqual(skill2.storage, PromptsStorage.local); - }); - - test('should list skill files from user home', async () => { - testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); - testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); - - const rootFolderName = 'list-skills-user-home'; - const rootFolder = `/${rootFolderName}`; - const rootFolderUri = URI.file(rootFolder); - - workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - - await mockFiles(fileService, [ - { - path: '/home/user/.copilot/skills/personal-skill/SKILL.md', - contents: [ - '---', - 'name: "Personal Skill"', - 'description: "A personal skill"', - '---', - 'Personal skill content', - ], - }, - { - path: '/home/user/.claude/skills/claude-personal/SKILL.md', - contents: [ - '---', - 'name: "Claude Personal Skill"', - 'description: "A Claude personal skill"', - '---', - 'Claude personal skill content', - ], - }, - ]); - - const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); - - const personalSkills = result.filter(s => s.storage === PromptsStorage.user); - assert.strictEqual(personalSkills.length, 2, 'Should find 2 personal skills'); - - const copilotSkill = personalSkills.find(s => s.uri.path.includes('.copilot')); - assert.ok(copilotSkill, 'Should find copilot personal skill'); - - const claudeSkill = personalSkills.find(s => s.uri.path.includes('.claude')); - assert.ok(claudeSkill, 'Should find claude personal skill'); - }); - - test('should not list skills when not in skill folder structure', async () => { - testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); - - const rootFolderName = 'no-skills'; - const rootFolder = `/${rootFolderName}`; - const rootFolderUri = URI.file(rootFolder); - - workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - - // Create files in non-skill locations - await mockFiles(fileService, [ - { - path: `${rootFolder}/.github/prompts/SKILL.md`, - contents: [ - '---', - 'name: "Not a skill"', - '---', - 'This is in prompts folder, not skills', - ], - }, - { - path: `${rootFolder}/SKILL.md`, - contents: [ - '---', - 'name: "Root skill"', - '---', - 'This is in root, not skills folder', - ], - }, - ]); - - const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); - - assert.strictEqual(result.length, 0, 'Should not find any skills in non-skill locations'); - }); - - test('should handle mixed workspace and user home skills', async () => { - testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); - testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, {}); - - const rootFolderName = 'mixed-skills'; - const rootFolder = `/${rootFolderName}`; - const rootFolderUri = URI.file(rootFolder); - - workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - - await mockFiles(fileService, [ - // Workspace skills - { - path: `${rootFolder}/.github/skills/workspace-skill/SKILL.md`, - contents: [ - '---', - 'name: "Workspace Skill"', - 'description: "A workspace skill"', - '---', - 'Workspace skill content', - ], - }, - // User home skills - { - path: '/home/user/.copilot/skills/personal-skill/SKILL.md', - contents: [ - '---', - 'name: "Personal Skill"', - 'description: "A personal skill"', - '---', - 'Personal skill content', - ], - }, - ]); - - const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); - - const workspaceSkills = result.filter(s => s.storage === PromptsStorage.local); - const userSkills = result.filter(s => s.storage === PromptsStorage.user); - - assert.strictEqual(workspaceSkills.length, 1, 'Should find 1 workspace skill'); - assert.strictEqual(userSkills.length, 1, 'Should find 1 user skill'); - }); - - test('should respect disabled default paths via config', async () => { - testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); - // Disable .github/skills, only .claude/skills should be searched - testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, { - '.github/skills': false, - '.claude/skills': true, - }); - - const rootFolderName = 'disabled-default-test'; - const rootFolder = `/${rootFolderName}`; - const rootFolderUri = URI.file(rootFolder); - - workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - - await mockFiles(fileService, [ - { - path: `${rootFolder}/.github/skills/github-skill/SKILL.md`, - contents: [ - '---', - 'name: "GitHub Skill"', - 'description: "Should NOT be found"', - '---', - 'This skill is in a disabled folder', - ], - }, - { - path: `${rootFolder}/.claude/skills/claude-skill/SKILL.md`, - contents: [ - '---', - 'name: "Claude Skill"', - 'description: "Should be found"', - '---', - 'This skill is in an enabled folder', - ], - }, - ]); - - const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); - - assert.strictEqual(result.length, 1, 'Should find only 1 skill (from enabled folder)'); - assert.ok(result[0].uri.path.includes('.claude/skills'), 'Should only find skill from .claude/skills'); - assert.ok(!result[0].uri.path.includes('.github/skills'), 'Should not find skill from disabled .github/skills'); - }); - - test('should expand tilde paths in custom locations', async () => { - testConfigService.setUserConfiguration(PromptsConfig.USE_AGENT_SKILLS, true); - // Add a tilde path as custom location - testConfigService.setUserConfiguration(PromptsConfig.SKILLS_LOCATION_KEY, { - '.github/skills': false, - '.claude/skills': false, - '~/my-custom-skills': true, - }); - - const rootFolderName = 'tilde-test'; - const rootFolder = `/${rootFolderName}`; - const rootFolderUri = URI.file(rootFolder); - - workspaceContextService.setWorkspace(testWorkspace(rootFolderUri)); - - // The mock user home is /home/user, so ~/my-custom-skills should resolve to /home/user/my-custom-skills - await mockFiles(fileService, [ - { - path: '/home/user/my-custom-skills/custom-skill/SKILL.md', - contents: [ - '---', - 'name: "Custom Skill"', - 'description: "A skill from tilde path"', - '---', - 'Skill content from ~/my-custom-skills', - ], - }, - ]); - - const result = await service.listPromptFiles(PromptsType.skill, CancellationToken.None); - - assert.strictEqual(result.length, 1, 'Should find 1 skill from tilde-expanded path'); - assert.ok(result[0].uri.path.includes('/home/user/my-custom-skills'), 'Path should be expanded from tilde'); - }); - }); - - suite('listPromptFiles - skills', () => { + suite('listPromptFiles - skills ', () => { teardown(() => { sinon.restore(); }); diff --git a/src/vs/workbench/electron-browser/desktop.main.ts b/src/vs/workbench/electron-browser/desktop.main.ts index 11e037e48f7eb..03bba5c4b30ab 100644 --- a/src/vs/workbench/electron-browser/desktop.main.ts +++ b/src/vs/workbench/electron-browser/desktop.main.ts @@ -210,7 +210,7 @@ export class DesktopMain extends Disposable { } // Default Account - const defaultAccountService = this._register(new DefaultAccountService()); + const defaultAccountService = this._register(new DefaultAccountService(productService)); serviceCollection.set(IDefaultAccountService, defaultAccountService); // Policies diff --git a/src/vs/workbench/services/accounts/common/defaultAccount.ts b/src/vs/workbench/services/accounts/common/defaultAccount.ts index 83d7e3ddb57d3..3bd289f8f31cd 100644 --- a/src/vs/workbench/services/accounts/common/defaultAccount.ts +++ b/src/vs/workbench/services/accounts/common/defaultAccount.ts @@ -12,21 +12,41 @@ import { CancellationToken } from '../../../../base/common/cancellation.js'; import { IExtensionService } from '../../extensions/common/extensions.js'; import { ILogService } from '../../../../platform/log/common/log.js'; import { IContextKey, IContextKeyService, RawContextKey } from '../../../../platform/contextkey/common/contextkey.js'; -import { Action2, registerAction2 } from '../../../../platform/actions/common/actions.js'; -import { localize } from '../../../../nls.js'; import { IWorkbenchContribution, registerWorkbenchContribution2, WorkbenchPhase } from '../../../common/contributions.js'; -import { Barrier, timeout } from '../../../../base/common/async.js'; +import { Barrier, ThrottledDelayer, timeout } from '../../../../base/common/async.js'; import { IConfigurationService } from '../../../../platform/configuration/common/configuration.js'; import { getErrorMessage } from '../../../../base/common/errors.js'; -import { IDefaultAccount } from '../../../../base/common/defaultAccount.js'; +import { IDefaultAccount, IDefaultAccountAuthenticationProvider, IEntitlementsData } from '../../../../base/common/defaultAccount.js'; import { isString } from '../../../../base/common/types.js'; import { IWorkbenchEnvironmentService } from '../../environment/common/environmentService.js'; import { isWeb } from '../../../../base/common/platform.js'; -import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IDefaultAccountProvider, IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; import { ITelemetryService } from '../../../../platform/telemetry/common/telemetry.js'; import { distinct } from '../../../../base/common/arrays.js'; -import { IInstantiationService, ServicesAccessor } from '../../../../platform/instantiation/common/instantiation.js'; -import { IDefaultAccountConfig } from '../../../../base/common/product.js'; +import { equals } from '../../../../base/common/objects.js'; +import { IDefaultChatAgent } from '../../../../base/common/product.js'; +import { IRequestContext } from '../../../../base/parts/request/common/request.js'; +import { IInstantiationService } from '../../../../platform/instantiation/common/instantiation.js'; + +interface IDefaultAccountConfig { + readonly preferredExtensions: string[]; + readonly authenticationProvider: { + readonly default: { + readonly id: string; + readonly name: string; + }; + readonly enterprise: { + readonly id: string; + readonly name: string; + }; + readonly enterpriseProviderConfig: string; + readonly enterpriseProviderUriSetting: string; + readonly scopes: string[][]; + }; + readonly tokenEntitlementUrl: string; + readonly entitlementUrl: string; + readonly mcpRegistryDataUrl: string; +} export const DEFAULT_ACCOUNT_SIGN_IN_COMMAND = 'workbench.actions.accounts.signIn'; @@ -38,24 +58,6 @@ const enum DefaultAccountStatus { const CONTEXT_DEFAULT_ACCOUNT_STATE = new RawContextKey('defaultAccountStatus', DefaultAccountStatus.Uninitialized); -interface IChatEntitlementsResponse { - readonly access_type_sku: string; - readonly copilot_plan: string; - readonly assigned_date: string; - readonly can_signup_for_limited: boolean; - readonly chat_enabled: boolean; - readonly analytics_tracking_id: string; - readonly limited_user_quotas?: { - readonly chat: number; - readonly completions: number; - }; - readonly monthly_quotas?: { - readonly chat: number; - readonly completions: number; - }; - readonly limited_user_reset_date: string; -} - interface ITokenEntitlementsResponse { token: string; } @@ -76,43 +78,129 @@ interface IMcpRegistryResponse { readonly mcp_registries: ReadonlyArray; } +function toDefaultAccountConfig(defaultChatAgent: IDefaultChatAgent): IDefaultAccountConfig { + return { + preferredExtensions: [ + defaultChatAgent.chatExtensionId, + defaultChatAgent.extensionId, + ], + authenticationProvider: { + default: { + id: defaultChatAgent.provider.default.id, + name: defaultChatAgent.provider.default.name, + }, + enterprise: { + id: defaultChatAgent.provider.enterprise.id, + name: defaultChatAgent.provider.enterprise.name, + }, + enterpriseProviderConfig: `${defaultChatAgent.completionsAdvancedSetting}.authProvider`, + enterpriseProviderUriSetting: defaultChatAgent.providerUriSetting, + scopes: defaultChatAgent.providerScopes, + }, + entitlementUrl: defaultChatAgent.entitlementUrl, + tokenEntitlementUrl: defaultChatAgent.tokenEntitlementUrl, + mcpRegistryDataUrl: defaultChatAgent.mcpRegistryDataUrl, + }; +} + export class DefaultAccountService extends Disposable implements IDefaultAccountService { declare _serviceBrand: undefined; - private _defaultAccount: IDefaultAccount | null | undefined = undefined; - get defaultAccount(): IDefaultAccount | null { return this._defaultAccount ?? null; } + private defaultAccount: IDefaultAccount | null = null; private readonly initBarrier = new Barrier(); private readonly _onDidChangeDefaultAccount = this._register(new Emitter()); readonly onDidChangeDefaultAccount = this._onDidChangeDefaultAccount.event; + private readonly defaultAccountConfig: IDefaultAccountConfig; + private defaultAccountProvider: IDefaultAccountProvider | null = null; + + constructor( + @IProductService productService: IProductService, + ) { + super(); + this.defaultAccountConfig = toDefaultAccountConfig(productService.defaultChatAgent); + } + async getDefaultAccount(): Promise { await this.initBarrier.wait(); return this.defaultAccount; } - setDefaultAccount(account: IDefaultAccount | null): void { - const oldAccount = this._defaultAccount; - this._defaultAccount = account; + getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { + if (this.defaultAccountProvider) { + return this.defaultAccountProvider.getDefaultAccountAuthenticationProvider(); + } + return { + ...this.defaultAccountConfig.authenticationProvider.default, + enterprise: false + }; + } - if (oldAccount !== this._defaultAccount) { - this._onDidChangeDefaultAccount.fire(this._defaultAccount); + setDefaultAccountProvider(provider: IDefaultAccountProvider): void { + if (this.defaultAccountProvider) { + throw new Error('Default account provider is already set'); } - this.initBarrier.open(); + this.defaultAccountProvider = provider; + provider.refresh().then(account => { + this.defaultAccount = account; + }).finally(() => { + this.initBarrier.open(); + this._register(provider.onDidChangeDefaultAccount(account => this.setDefaultAccount(account))); + }); + } + + async refresh(): Promise { + await this.initBarrier.wait(); + + const account = await this.defaultAccountProvider?.refresh(); + this.setDefaultAccount(account ?? null); + return this.defaultAccount; } + async signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise { + await this.initBarrier.wait(); + return this.defaultAccountProvider?.signIn(options) ?? null; + } + + private setDefaultAccount(account: IDefaultAccount | null): void { + if (equals(this.defaultAccount, account)) { + return; + } + this.defaultAccount = account; + this._onDidChangeDefaultAccount.fire(this.defaultAccount); + } } -class DefaultAccountSetup extends Disposable { +type DefaultAccountStatusTelemetry = { + status: string; + initial: boolean; +}; + +type DefaultAccountStatusTelemetryClassification = { + owner: 'sandy081'; + comment: 'Log default account availability status'; + status: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Indicates whether default account is available or not.' }; + initial: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Indicates whether this is the initial status report.' }; +}; + +class DefaultAccountProvider extends Disposable implements IDefaultAccountProvider { + + private _defaultAccount: IDefaultAccount | null = null; + get defaultAccount(): IDefaultAccount | null { return this._defaultAccount ?? null; } + + private readonly _onDidChangeDefaultAccount = this._register(new Emitter()); + readonly onDidChangeDefaultAccount = this._onDidChangeDefaultAccount.event; - private defaultAccount: IDefaultAccount | null = null; private readonly accountStatusContext: IContextKey; + private initialized = false; + private readonly initPromise: Promise; + private readonly updateThrottler = this._register(new ThrottledDelayer(100)); constructor( private readonly defaultAccountConfig: IDefaultAccountConfig, - @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, @IConfigurationService private readonly configurationService: IConfigurationService, @IAuthenticationService private readonly authenticationService: IAuthenticationService, @IAuthenticationExtensionsService private readonly authenticationExtensionsService: IAuthenticationExtensionsService, @@ -125,84 +213,121 @@ class DefaultAccountSetup extends Disposable { ) { super(); this.accountStatusContext = CONTEXT_DEFAULT_ACCOUNT_STATE.bindTo(contextKeyService); + this.initPromise = this.init() + .finally(() => { + this.telemetryService.publicLog2('defaultaccount:status', { status: this.defaultAccount ? 'available' : 'unavailable', initial: true }); + this.initialized = true; + }); } - async setup(): Promise { - this.logService.debug('[DefaultAccount] Starting initialization'); - let defaultAccount: IDefaultAccount | null = null; + private async init(): Promise { + if (isWeb && !this.environmentService.remoteAuthority) { + this.logService.debug('[DefaultAccount] Running in web without remote, skipping initialization'); + return; + } + try { - defaultAccount = await this.fetchDefaultAccount(); + await this.extensionService.whenInstalledExtensionsRegistered(); + this.logService.debug('[DefaultAccount] Installed extensions registered.'); } catch (error) { - this.logService.error('[DefaultAccount] Error during initialization', getErrorMessage(error)); + this.logService.error('[DefaultAccount] Error while waiting for installed extensions to be registered', getErrorMessage(error)); } - this.setDefaultAccount(defaultAccount); + this.logService.debug('[DefaultAccount] Starting initialization'); + await this.doUpdateDefaultAccount(); this.logService.debug('[DefaultAccount] Initialization complete'); - type DefaultAccountStatusTelemetry = { - status: string; - initial: boolean; - }; - type DefaultAccountStatusTelemetryClassification = { - owner: 'sandy081'; - comment: 'Log default account availability status'; - status: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Indicates whether default account is available or not.' }; - initial: { classification: 'SystemMetaData'; purpose: 'PerformanceAndHealth'; comment: 'Indicates whether this is the initial status report.' }; - }; - this.telemetryService.publicLog2('defaultaccount:status', { status: this.defaultAccount ? 'available' : 'unavailable', initial: true }); - - this._register(this.defaultAccountService.onDidChangeDefaultAccount(account => { + this._register(this.onDidChangeDefaultAccount(account => { this.telemetryService.publicLog2('defaultaccount:status', { status: account ? 'available' : 'unavailable', initial: false }); })); - this._register(this.authenticationService.onDidChangeSessions(async e => { - if (e.providerId !== this.getDefaultAccountProviderId()) { + this._register(this.authenticationService.onDidChangeSessions(e => { + const defaultAccountProvider = this.getDefaultAccountAuthenticationProvider(); + if (e.providerId !== defaultAccountProvider.id) { return; } if (this.defaultAccount && e.event.removed?.some(session => session.id === this.defaultAccount?.sessionId)) { this.setDefaultAccount(null); } else { - this.setDefaultAccount(await this.getDefaultAccountFromAuthenticatedSessions(e.providerId, this.defaultAccountConfig.authenticationProvider.scopes)); + this.logService.debug('[DefaultAccount] Sessions changed for default account provider, updating default account'); + this.updateDefaultAccount(); } })); this._register(this.authenticationExtensionsService.onDidChangeAccountPreference(async e => { - if (e.providerId !== this.getDefaultAccountProviderId()) { + const defaultAccountProvider = this.getDefaultAccountAuthenticationProvider(); + if (e.providerId !== defaultAccountProvider.id) { + return; + } + this.logService.debug('[DefaultAccount] Account preference changed for default account provider, updating default account'); + this.updateDefaultAccount(); + })); + + this._register(this.authenticationService.onDidRegisterAuthenticationProvider(e => { + const defaultAccountProvider = this.getDefaultAccountAuthenticationProvider(); + if (e.id !== defaultAccountProvider.id) { return; } - this.setDefaultAccount(await this.getDefaultAccountFromAuthenticatedSessions(e.providerId, this.defaultAccountConfig.authenticationProvider.scopes)); + this.logService.debug('[DefaultAccount] Default account provider registered, updating default account'); + this.updateDefaultAccount(); + })); + + this._register(this.authenticationService.onDidUnregisterAuthenticationProvider(e => { + const defaultAccountProvider = this.getDefaultAccountAuthenticationProvider(); + if (e.id !== defaultAccountProvider.id) { + return; + } + this.logService.debug('[DefaultAccount] Default account provider unregistered, updating default account'); + this.updateDefaultAccount(); })); } - private async fetchDefaultAccount(): Promise { - if (isWeb && !this.environmentService.remoteAuthority) { - this.logService.debug('[DefaultAccount] Running in web without remote, skipping initialization'); - return null; + async refresh(): Promise { + if (!this.initialized) { + await this.initPromise; + return this.defaultAccount; } - const defaultAccountProviderId = this.getDefaultAccountProviderId(); - this.logService.debug('[DefaultAccount] Default account provider ID:', defaultAccountProviderId); - if (!defaultAccountProviderId) { - return null; + this.logService.debug('[DefaultAccount] Refreshing default account'); + await this.updateDefaultAccount(); + return this.defaultAccount; + } + + private async updateDefaultAccount(): Promise { + await this.updateThrottler.trigger(() => this.doUpdateDefaultAccount()); + } + + private async doUpdateDefaultAccount(): Promise { + try { + const defaultAccount = await this.fetchDefaultAccount(); + this.setDefaultAccount(defaultAccount); + } catch (error) { + this.logService.error('[DefaultAccount] Error while updating default account', getErrorMessage(error)); } + } - await this.extensionService.whenInstalledExtensionsRegistered(); - this.logService.debug('[DefaultAccount] Installed extensions registered.'); + private async fetchDefaultAccount(): Promise { + const defaultAccountProvider = this.getDefaultAccountAuthenticationProvider(); + this.logService.debug('[DefaultAccount] Default account provider ID:', defaultAccountProvider.id); - const declaredProvider = this.authenticationService.declaredProviders.find(provider => provider.id === defaultAccountProviderId); + const declaredProvider = this.authenticationService.declaredProviders.find(provider => provider.id === defaultAccountProvider.id); if (!declaredProvider) { - this.logService.info(`[DefaultAccount] Authentication provider is not declared.`, defaultAccountProviderId); + this.logService.info(`[DefaultAccount] Authentication provider is not declared.`, defaultAccountProvider); return null; } - this.registerSignInAction(this.defaultAccountConfig.authenticationProvider.scopes[0]); - return await this.getDefaultAccountFromAuthenticatedSessions(defaultAccountProviderId, this.defaultAccountConfig.authenticationProvider.scopes); + return await this.getDefaultAccountFromAuthenticatedSessions(defaultAccountProvider, this.defaultAccountConfig.authenticationProvider.scopes); } private setDefaultAccount(account: IDefaultAccount | null): void { - this.defaultAccount = account; - this.defaultAccountService.setDefaultAccount(this.defaultAccount); - if (this.defaultAccount) { + if (equals(this._defaultAccount, account)) { + return; + } + + this.logService.trace('[DefaultAccount] Updating default account:', account); + this._defaultAccount = account; + this._onDidChangeDefaultAccount.fire(this._defaultAccount); + if (this._defaultAccount) { this.accountStatusContext.set(DefaultAccountStatus.Available); this.logService.debug('[DefaultAccount] Account status set to Available'); } else { @@ -223,50 +348,56 @@ class DefaultAccountSetup extends Disposable { return result; } - private async getDefaultAccountFromAuthenticatedSessions(authProviderId: string, scopes: string[][]): Promise { + private async getDefaultAccountFromAuthenticatedSessions(authenticationProvider: IDefaultAccountAuthenticationProvider, scopes: string[][]): Promise { try { - this.logService.debug('[DefaultAccount] Getting Default Account from authenticated sessions for provider:', authProviderId); - const session = await this.findMatchingProviderSession(authProviderId, scopes); + this.logService.debug('[DefaultAccount] Getting Default Account from authenticated sessions for provider:', authenticationProvider.id); + const sessions = await this.findMatchingProviderSession(authenticationProvider.id, scopes); - if (!session) { - this.logService.debug('[DefaultAccount] No matching session found for provider:', authProviderId); + if (!sessions) { + this.logService.debug('[DefaultAccount] No matching session found for provider:', authenticationProvider.id); return null; } - const [chatEntitlements, tokenEntitlements] = await Promise.all([ - this.getChatEntitlements(session.accessToken), - this.getTokenEntitlements(session.accessToken), + const [entitlementsData, policyData] = await Promise.all([ + this.getEntitlements(sessions), + this.getTokenEntitlements(sessions), ]); - const mcpRegistryProvider = tokenEntitlements.mcp ? await this.getMcpRegistryProvider(session.accessToken) : undefined; - - const account = { - sessionId: session.id, - enterprise: this.isEnterpriseAuthenticationProvider(authProviderId) || session.account.label.includes('_'), - ...chatEntitlements, - ...tokenEntitlements, - mcpRegistryUrl: mcpRegistryProvider?.url, - mcpAccess: mcpRegistryProvider?.registry_access, + const mcpRegistryProvider = policyData.mcp ? await this.getMcpRegistryProvider(sessions) : undefined; + + const account: IDefaultAccount = { + authenticationProvider, + sessionId: sessions[0].id, + enterprise: authenticationProvider.enterprise || sessions[0].account.label.includes('_'), + entitlementsData, + policyData: { + chat_agent_enabled: policyData.chat_agent_enabled, + chat_preview_features_enabled: policyData.chat_preview_features_enabled, + mcp: policyData.mcp, + mcpRegistryUrl: mcpRegistryProvider?.url, + mcpAccess: mcpRegistryProvider?.registry_access, + } }; - this.logService.debug('[DefaultAccount] Successfully created default account for provider:', authProviderId); + this.logService.debug('[DefaultAccount] Successfully created default account for provider:', authenticationProvider.id); return account; } catch (error) { - this.logService.error('[DefaultAccount] Failed to create default account for provider:', authProviderId, getErrorMessage(error)); + this.logService.error('[DefaultAccount] Failed to create default account for provider:', authenticationProvider.id, getErrorMessage(error)); return null; } } - private async findMatchingProviderSession(authProviderId: string, allScopes: string[][]): Promise { + private async findMatchingProviderSession(authProviderId: string, allScopes: string[][]): Promise { const sessions = await this.getSessions(authProviderId); + const matchingSessions = []; for (const session of sessions) { this.logService.debug('[DefaultAccount] Checking session with scopes', session.scopes); for (const scopes of allScopes) { if (this.scopesMatch(session.scopes, scopes)) { - return session; + matchingSessions.push(session); } } } - return undefined; + return matchingSessions.length > 0 ? matchingSessions : undefined; } private async getSessions(authProviderId: string): Promise { @@ -303,7 +434,7 @@ class DefaultAccountSetup extends Disposable { return expectedScopes.every(scope => scopes.includes(scope)); } - private async getTokenEntitlements(accessToken: string): Promise> { + private async getTokenEntitlements(sessions: AuthenticationSession[]): Promise<{ mcp?: boolean; chat_preview_features_enabled?: boolean; chat_agent_enabled?: boolean }> { const tokenEntitlementsUrl = this.getTokenEntitlementUrl(); if (!tokenEntitlementsUrl) { this.logService.debug('[DefaultAccount] No token entitlements URL found'); @@ -311,17 +442,18 @@ class DefaultAccountSetup extends Disposable { } this.logService.debug('[DefaultAccount] Fetching token entitlements from:', tokenEntitlementsUrl); - try { - const chatContext = await this.requestService.request({ - type: 'GET', - url: tokenEntitlementsUrl, - disableCache: true, - headers: { - 'Authorization': `Bearer ${accessToken}` - } - }, CancellationToken.None); + const response = await this.request(tokenEntitlementsUrl, 'GET', undefined, sessions, CancellationToken.None); + if (!response) { + return {}; + } - const chatData = await asJson(chatContext); + if (response.res.statusCode && response.res.statusCode !== 200) { + this.logService.trace(`[DefaultAccount] unexpected status code ${response.res.statusCode} while fetching token entitlements`); + return {}; + } + + try { + const chatData = await asJson(response); if (chatData) { const tokenMap = this.extractFromToken(chatData.token); return { @@ -340,53 +472,59 @@ class DefaultAccountSetup extends Disposable { return {}; } - private async getChatEntitlements(accessToken: string): Promise> { - const chatEntitlementsUrl = this.getChatEntitlementUrl(); - if (!chatEntitlementsUrl) { + private async getEntitlements(sessions: AuthenticationSession[]): Promise { + const entitlementUrl = this.getEntitlementUrl(); + if (!entitlementUrl) { this.logService.debug('[DefaultAccount] No chat entitlements URL found'); - return {}; + return undefined; } - this.logService.debug('[DefaultAccount] Fetching chat entitlements from:', chatEntitlementsUrl); - try { - const context = await this.requestService.request({ - type: 'GET', - url: chatEntitlementsUrl, - disableCache: true, - headers: { - 'Authorization': `Bearer ${accessToken}` - } - }, CancellationToken.None); + this.logService.debug('[DefaultAccount] Fetching entitlements from:', entitlementUrl); + const response = await this.request(entitlementUrl, 'GET', undefined, sessions, CancellationToken.None); + if (!response) { + return undefined; + } - const data = await asJson(context); + if (response.res.statusCode && response.res.statusCode !== 200) { + this.logService.trace(`[DefaultAccount] unexpected status code ${response.res.statusCode} while fetching entitlements`); + return ( + response.res.statusCode === 401 || // oauth token being unavailable (expired/revoked) + response.res.statusCode === 404 // missing scopes/permissions, service pretends the endpoint doesn't exist + ) ? null : undefined; + } + + try { + const data = await asJson(response); if (data) { return data; } - this.logService.error('Failed to fetch entitlements', 'No data returned'); + this.logService.error('[DefaultAccount] Failed to fetch entitlements', 'No data returned'); } catch (error) { - this.logService.error('Failed to fetch entitlements', getErrorMessage(error)); + this.logService.error('[DefaultAccount] Failed to fetch entitlements', getErrorMessage(error)); } - return {}; + return undefined; } - private async getMcpRegistryProvider(accessToken: string): Promise { + private async getMcpRegistryProvider(sessions: AuthenticationSession[]): Promise { const mcpRegistryDataUrl = this.getMcpRegistryDataUrl(); if (!mcpRegistryDataUrl) { this.logService.debug('[DefaultAccount] No MCP registry data URL found'); return undefined; } - try { - const context = await this.requestService.request({ - type: 'GET', - url: mcpRegistryDataUrl, - disableCache: true, - headers: { - 'Authorization': `Bearer ${accessToken}` - } - }, CancellationToken.None); + this.logService.debug('[DefaultAccount] Fetching MCP registry data from:', mcpRegistryDataUrl); + const response = await this.request(mcpRegistryDataUrl, 'GET', undefined, sessions, CancellationToken.None); + if (!response) { + return undefined; + } - const data = await asJson(context); + if (response.res.statusCode && response.res.statusCode !== 200) { + this.logService.trace(`[DefaultAccount] unexpected status code ${response.res.statusCode} while fetching MCP registry data`); + return undefined; + } + + try { + const data = await asJson(response); if (data) { this.logService.debug('Fetched MCP registry providers', data.mcp_registries); return data.mcp_registries[0]; @@ -398,8 +536,56 @@ class DefaultAccountSetup extends Disposable { return undefined; } - private getChatEntitlementUrl(): string | undefined { - if (this.isEnterpriseAuthenticationProvider(this.getDefaultAccountProviderId())) { + private async request(url: string, type: 'GET', body: undefined, sessions: AuthenticationSession[], token: CancellationToken): Promise; + private async request(url: string, type: 'POST', body: object, sessions: AuthenticationSession[], token: CancellationToken): Promise; + private async request(url: string, type: 'GET' | 'POST', body: object | undefined, sessions: AuthenticationSession[], token: CancellationToken): Promise { + let lastResponse: IRequestContext | undefined; + + for (const session of sessions) { + if (token.isCancellationRequested) { + return lastResponse; + } + + try { + const response = await this.requestService.request({ + type, + url, + data: type === 'POST' ? JSON.stringify(body) : undefined, + disableCache: true, + headers: { + 'Authorization': `Bearer ${session.accessToken}` + } + }, token); + + const status = response.res.statusCode; + if (status && status !== 200) { + lastResponse = response; + continue; // try next session + } + + return response; + } catch (error) { + if (!token.isCancellationRequested) { + this.logService.error(`[chat entitlement] request: error ${error}`); + } + } + } + + if (!lastResponse) { + this.logService.trace('[DefaultAccount]: No response received for request', url); + return undefined; + } + + if (lastResponse.res.statusCode && lastResponse.res.statusCode !== 200) { + this.logService.trace(`[DefaultAccount]: unexpected status code ${lastResponse.res.statusCode} for request`, url); + return undefined; + } + + return lastResponse; + } + + private getEntitlementUrl(): string | undefined { + if (this.getDefaultAccountAuthenticationProvider().enterprise) { try { const enterpriseUrl = this.getEnterpriseUrl(); if (!enterpriseUrl) { @@ -411,11 +597,11 @@ class DefaultAccountSetup extends Disposable { } } - return this.defaultAccountConfig.chatEntitlementUrl; + return this.defaultAccountConfig.entitlementUrl; } private getTokenEntitlementUrl(): string | undefined { - if (this.isEnterpriseAuthenticationProvider(this.getDefaultAccountProviderId())) { + if (this.getDefaultAccountAuthenticationProvider().enterprise) { try { const enterpriseUrl = this.getEnterpriseUrl(); if (!enterpriseUrl) { @@ -431,7 +617,7 @@ class DefaultAccountSetup extends Disposable { } private getMcpRegistryDataUrl(): string | undefined { - if (this.isEnterpriseAuthenticationProvider(this.getDefaultAccountProviderId())) { + if (this.getDefaultAccountAuthenticationProvider().enterprise) { try { const enterpriseUrl = this.getEnterpriseUrl(); if (!enterpriseUrl) { @@ -446,15 +632,17 @@ class DefaultAccountSetup extends Disposable { return this.defaultAccountConfig.mcpRegistryDataUrl; } - private getDefaultAccountProviderId(): string { - if (this.configurationService.getValue(this.defaultAccountConfig.authenticationProvider.enterpriseProviderConfig) === this.defaultAccountConfig?.authenticationProvider.enterpriseProviderId) { - return this.defaultAccountConfig.authenticationProvider.enterpriseProviderId; + getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { + if (this.configurationService.getValue(this.defaultAccountConfig.authenticationProvider.enterpriseProviderConfig) === this.defaultAccountConfig.authenticationProvider.enterprise.id) { + return { + ...this.defaultAccountConfig.authenticationProvider.enterprise, + enterprise: true + }; } - return this.defaultAccountConfig.authenticationProvider.id; - } - - private isEnterpriseAuthenticationProvider(providerId: string): boolean { - return providerId === this.defaultAccountConfig.authenticationProvider.enterpriseProviderId; + return { + ...this.defaultAccountConfig.authenticationProvider.default, + enterprise: false + }; } private getEnterpriseUrl(): URL | undefined { @@ -465,35 +653,27 @@ class DefaultAccountSetup extends Disposable { return new URL(value); } - private registerSignInAction(defaultAccountScopes: string[]): void { - const that = this; - this._register(registerAction2(class extends Action2 { - constructor() { - super({ - id: DEFAULT_ACCOUNT_SIGN_IN_COMMAND, - title: localize('sign in', "Sign in"), - }); - } - async run(accessor: ServicesAccessor, options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise { - const authProviderId = that.getDefaultAccountProviderId(); - if (!authProviderId) { - throw new Error('No default account provider configured'); - } - const { additionalScopes, ...sessionOptions } = options ?? {}; - const scopes = additionalScopes ? distinct([...defaultAccountScopes, ...additionalScopes]) : defaultAccountScopes; - const session = await that.authenticationService.createSession(authProviderId, scopes, sessionOptions); - for (const preferredExtension of that.defaultAccountConfig.preferredExtensions) { - that.authenticationExtensionsService.updateAccountPreference(preferredExtension, authProviderId, session.account); - } - } - })); + async signIn(options?: { additionalScopes?: readonly string[];[key: string]: unknown }): Promise { + const authProvider = this.getDefaultAccountAuthenticationProvider(); + if (!authProvider) { + throw new Error('No default account provider configured'); + } + const { additionalScopes, ...sessionOptions } = options ?? {}; + const defaultAccountScopes = this.defaultAccountConfig.authenticationProvider.scopes[0]; + const scopes = additionalScopes ? distinct([...defaultAccountScopes, ...additionalScopes]) : defaultAccountScopes; + const session = await this.authenticationService.createSession(authProvider.id, scopes, sessionOptions); + for (const preferredExtension of this.defaultAccountConfig.preferredExtensions) { + this.authenticationExtensionsService.updateAccountPreference(preferredExtension, authProvider.id, session.account); + } + await this.updateDefaultAccount(); + return this.defaultAccount; } } -class DefaultAccountSetupContribution extends Disposable implements IWorkbenchContribution { +class DefaultAccountProviderContribution extends Disposable implements IWorkbenchContribution { - static ID = 'workbench.contributions.defaultAccountSetup'; + static ID = 'workbench.contributions.defaultAccountProvider'; constructor( @IProductService productService: IProductService, @@ -502,13 +682,9 @@ class DefaultAccountSetupContribution extends Disposable implements IWorkbenchCo @ILogService logService: ILogService, ) { super(); - if (productService.defaultAccount) { - this._register(instantiationService.createInstance(DefaultAccountSetup, productService.defaultAccount)).setup(); - } else { - defaultAccountService.setDefaultAccount(null); - logService.debug('[DefaultAccount] No default account configuration in product service, skipping initialization'); - } + const defaultAccountProvider = this._register(instantiationService.createInstance(DefaultAccountProvider, toDefaultAccountConfig(productService.defaultChatAgent))); + defaultAccountService.setDefaultAccountProvider(defaultAccountProvider); } } -registerWorkbenchContribution2('workbench.contributions.defaultAccountManagement', DefaultAccountSetupContribution, WorkbenchPhase.AfterRestored); +registerWorkbenchContribution2(DefaultAccountProviderContribution.ID, DefaultAccountProviderContribution, WorkbenchPhase.AfterRestored); diff --git a/src/vs/workbench/services/chat/common/chatEntitlementService.ts b/src/vs/workbench/services/chat/common/chatEntitlementService.ts index 482a93f73e081..5bc38d53b2c9d 100644 --- a/src/vs/workbench/services/chat/common/chatEntitlementService.ts +++ b/src/vs/workbench/services/chat/common/chatEntitlementService.ts @@ -20,7 +20,7 @@ import { IProductService } from '../../../../platform/product/common/productServ import { asText, IRequestService } from '../../../../platform/request/common/request.js'; import { IStorageService, StorageScope, StorageTarget } from '../../../../platform/storage/common/storage.js'; import { ITelemetryService, TelemetryLevel } from '../../../../platform/telemetry/common/telemetry.js'; -import { AuthenticationSession, AuthenticationSessionAccount, IAuthenticationExtensionsService, IAuthenticationService } from '../../authentication/common/authentication.js'; +import { AuthenticationSession, IAuthenticationService } from '../../authentication/common/authentication.js'; import { IOpenerService } from '../../../../platform/opener/common/opener.js'; import { URI } from '../../../../base/common/uri.js'; import Severity from '../../../../base/common/severity.js'; @@ -28,9 +28,10 @@ import { IWorkbenchEnvironmentService } from '../../environment/common/environme import { isWeb } from '../../../../base/common/platform.js'; import { ILifecycleService } from '../../lifecycle/common/lifecycle.js'; import { Mutable } from '../../../../base/common/types.js'; -import { distinct } from '../../../../base/common/arrays.js'; import { InstantiationType, registerSingleton } from '../../../../platform/instantiation/common/extensions.js'; import { IObservable, observableFromEvent } from '../../../../base/common/observable.js'; +import { IDefaultAccountService } from '../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IDefaultAccount, IEntitlementsData } from '../../../../base/common/defaultAccount.js'; export namespace ChatEntitlementContextKeys { @@ -179,16 +180,10 @@ export function isProUser(chatEntitlement: ChatEntitlement): boolean { //#region Service Implementation -const defaultChat = { - extensionId: product.defaultChatAgent?.extensionId ?? '', - chatExtensionId: product.defaultChatAgent?.chatExtensionId ?? '', +const defaultChatAgent = { upgradePlanUrl: product.defaultChatAgent?.upgradePlanUrl ?? '', - provider: product.defaultChatAgent?.provider ?? { default: { id: '' }, enterprise: { id: '' } }, providerUriSetting: product.defaultChatAgent?.providerUriSetting ?? '', - providerScopes: product.defaultChatAgent?.providerScopes ?? [[]], - entitlementUrl: product.defaultChatAgent?.entitlementUrl ?? '', entitlementSignupLimitedUrl: product.defaultChatAgent?.entitlementSignupLimitedUrl ?? '', - completionsAdvancedSetting: product.defaultChatAgent?.completionsAdvancedSetting ?? '', chatQuotaExceededContext: product.defaultChatAgent?.chatQuotaExceededContext ?? '', completionsQuotaExceededContext: product.defaultChatAgent?.completionsQuotaExceededContext ?? '' }; @@ -370,8 +365,8 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme private readonly completionsQuotaExceededContextKey: IContextKey; private ExtensionQuotaContextKeys = { - chatQuotaExceeded: defaultChat.chatQuotaExceededContext, - completionsQuotaExceeded: defaultChat.completionsQuotaExceededContext, + chatQuotaExceeded: defaultChatAgent.chatQuotaExceededContext, + completionsQuotaExceeded: defaultChatAgent.completionsQuotaExceededContext, }; private registerListeners(): void { @@ -486,7 +481,7 @@ export class ChatEntitlementService extends Disposable implements IChatEntitleme //#endregion async update(token: CancellationToken): Promise { - await this.requests?.value.forceResolveEntitlement(undefined, token); + await this.requests?.value.forceResolveEntitlement(token); } } @@ -516,44 +511,6 @@ type EntitlementEvent = { quotaResetDate: string | undefined; }; -interface IQuotaSnapshotResponse { - readonly entitlement: number; - readonly overage_count: number; - readonly overage_permitted: boolean; - readonly percent_remaining: number; - readonly remaining: number; - readonly unlimited: boolean; -} - -interface ILegacyQuotaSnapshotResponse { - readonly limited_user_quotas?: { - readonly chat: number; - readonly completions: number; - }; - readonly monthly_quotas?: { - readonly chat: number; - readonly completions: number; - }; -} - -interface IEntitlementsResponse extends ILegacyQuotaSnapshotResponse { - readonly access_type_sku: string; - readonly assigned_date: string; - readonly can_signup_for_limited: boolean; - readonly chat_enabled: boolean; - readonly copilot_plan: string; - readonly organization_login_list: string[]; - readonly analytics_tracking_id: string; - readonly limited_user_reset_date?: string; // for Copilot Free - readonly quota_reset_date?: string; // for all other Copilot SKUs - readonly quota_reset_date_utc?: string; // for all other Copilot SKUs (includes time) - readonly quota_snapshots?: { - chat?: IQuotaSnapshotResponse; - completions?: IQuotaSnapshotResponse; - premium_interactions?: IQuotaSnapshotResponse; - }; -} - interface IEntitlements { readonly entitlement: ChatEntitlement; readonly organisations?: string[]; @@ -584,31 +541,21 @@ interface IQuotas { export class ChatEntitlementRequests extends Disposable { - static providerId(configurationService: IConfigurationService): string { - if (configurationService.getValue(`${defaultChat.completionsAdvancedSetting}.authProvider`) === defaultChat.provider.enterprise.id) { - return defaultChat.provider.enterprise.id; - } - - return defaultChat.provider.default.id; - } - private state: IEntitlements; private pendingResolveCts = new CancellationTokenSource(); - private didResolveEntitlements = false; constructor( private readonly context: ChatEntitlementContext, private readonly chatQuotasAccessor: IChatQuotasAccessor, @ITelemetryService private readonly telemetryService: ITelemetryService, - @IAuthenticationService private readonly authenticationService: IAuthenticationService, @ILogService private readonly logService: ILogService, @IRequestService private readonly requestService: IRequestService, @IDialogService private readonly dialogService: IDialogService, @IOpenerService private readonly openerService: IOpenerService, - @IConfigurationService private readonly configurationService: IConfigurationService, - @IAuthenticationExtensionsService private readonly authenticationExtensionsService: IAuthenticationExtensionsService, @ILifecycleService private readonly lifecycleService: ILifecycleService, + @IDefaultAccountService private readonly defaultAccountService: IDefaultAccountService, + @IAuthenticationService private readonly authenticationService: IAuthenticationService, ) { super(); @@ -620,25 +567,7 @@ export class ChatEntitlementRequests extends Disposable { } private registerListeners(): void { - this._register(this.authenticationService.onDidChangeDeclaredProviders(() => this.resolve())); - - this._register(this.authenticationService.onDidChangeSessions(e => { - if (e.providerId === ChatEntitlementRequests.providerId(this.configurationService)) { - this.resolve(); - } - })); - - this._register(this.authenticationService.onDidRegisterAuthenticationProvider(e => { - if (e.id === ChatEntitlementRequests.providerId(this.configurationService)) { - this.resolve(); - } - })); - - this._register(this.authenticationService.onDidUnregisterAuthenticationProvider(e => { - if (e.id === ChatEntitlementRequests.providerId(this.configurationService)) { - this.resolve(); - } - })); + this._register(this.defaultAccountService.onDidChangeDefaultAccount(() => this.resolve())); this._register(this.context.onDidChange(() => { if (!this.context.state.installed || this.context.state.disabled || this.context.state.entitlement === ChatEntitlement.Unknown) { @@ -654,149 +583,65 @@ export class ChatEntitlementRequests extends Disposable { this.pendingResolveCts.dispose(true); const cts = this.pendingResolveCts = new CancellationTokenSource(); - const session = await this.findMatchingProviderSession(cts.token); + const defaultAccount = await this.defaultAccountService.getDefaultAccount(); if (cts.token.isCancellationRequested) { return; } // Immediately signal whether we have a session or not let state: IEntitlements | undefined = undefined; - if (session) { + if (defaultAccount) { // Do not overwrite any state we have already if (this.state.entitlement === ChatEntitlement.Unknown) { state = { entitlement: ChatEntitlement.Unresolved }; } } else { - this.didResolveEntitlements = false; // reset so that we resolve entitlements fresh when signed in again state = { entitlement: ChatEntitlement.Unknown }; } if (state) { this.update(state); } - if (session && !this.didResolveEntitlements) { + if (defaultAccount) { // Afterwards resolve entitlement with a network request // but only unless it was not already resolved before. - await this.resolveEntitlement(session, cts.token); - } - } - - private async findMatchingProviderSession(token: CancellationToken): Promise { - const sessions = await this.doGetSessions(ChatEntitlementRequests.providerId(this.configurationService)); - if (token.isCancellationRequested) { - return undefined; - } - - const matchingSessions = new Set(); - for (const session of sessions) { - for (const scopes of defaultChat.providerScopes) { - if (this.includesScopes(session.scopes, scopes)) { - matchingSessions.add(session); - } - } - } - - // We intentionally want to return an array of matching sessions and - // not just the first, because it is possible that a matching session - // has an expired token. As such, we want to try them all until we - // succeeded with the request. - return matchingSessions.size > 0 ? Array.from(matchingSessions) : undefined; - } - - private async doGetSessions(providerId: string): Promise { - const preferredAccountName = this.authenticationExtensionsService.getAccountPreference(defaultChat.chatExtensionId, providerId) ?? this.authenticationExtensionsService.getAccountPreference(defaultChat.extensionId, providerId); - let preferredAccount: AuthenticationSessionAccount | undefined; - for (const account of await this.authenticationService.getAccounts(providerId)) { - if (account.label === preferredAccountName) { - preferredAccount = account; - break; - } + await this.resolveEntitlement(defaultAccount, cts.token); } - - try { - return await this.authenticationService.getSessions(providerId, undefined, { account: preferredAccount }); - } catch (error) { - // ignore - errors can throw if a provider is not registered - } - - return []; - } - - private includesScopes(scopes: ReadonlyArray, expectedScopes: string[]): boolean { - return expectedScopes.every(scope => scopes.includes(scope)); } - private async resolveEntitlement(sessions: AuthenticationSession[], token: CancellationToken): Promise { - const entitlements = await this.doResolveEntitlement(sessions, token); + private async resolveEntitlement(defaultAccount: IDefaultAccount, token: CancellationToken): Promise { + const entitlements = await this.doResolveEntitlement(defaultAccount, token); if (typeof entitlements?.entitlement === 'number' && !token.isCancellationRequested) { - this.didResolveEntitlements = true; this.update(entitlements); } - return entitlements; } - private async doResolveEntitlement(sessions: AuthenticationSession[], token: CancellationToken): Promise { - if (token.isCancellationRequested) { - return undefined; - } - - const response = await this.request(this.getEntitlementUrl(), 'GET', undefined, sessions, token); - if (token.isCancellationRequested) { - return undefined; - } - - if (!response) { - this.logService.trace('[chat entitlement]: no response'); - return { entitlement: ChatEntitlement.Unresolved }; - } - - if (response.res.statusCode && response.res.statusCode !== 200) { - this.logService.trace(`[chat entitlement]: unexpected status code ${response.res.statusCode}`); - return ( - response.res.statusCode === 401 || // oauth token being unavailable (expired/revoked) - response.res.statusCode === 404 // missing scopes/permissions, service pretends the endpoint doesn't exist - ) ? { entitlement: ChatEntitlement.Unknown /* treat as signed out */ } : { entitlement: ChatEntitlement.Unresolved }; - } - - let responseText: string | null = null; - try { - responseText = await asText(response); - } catch (error) { - // ignore - handled below - } + private async doResolveEntitlement(defaultAccount: IDefaultAccount, token: CancellationToken): Promise { if (token.isCancellationRequested) { return undefined; } - if (!responseText) { - this.logService.trace('[chat entitlement]: response has no content'); - return { entitlement: ChatEntitlement.Unresolved }; - } - - let entitlementsResponse: IEntitlementsResponse; - try { - entitlementsResponse = JSON.parse(responseText); - this.logService.trace(`[chat entitlement]: parsed result is ${JSON.stringify(entitlementsResponse)}`); - } catch (err) { - this.logService.trace(`[chat entitlement]: error parsing response (${err})`); - return { entitlement: ChatEntitlement.Unresolved }; + const entitlementsData = defaultAccount.entitlementsData; + if (!entitlementsData) { + this.logService.trace('[chat entitlement]: no entitlements data available on default account'); + return { entitlement: entitlementsData === null ? ChatEntitlement.Unknown : ChatEntitlement.Unresolved }; } let entitlement: ChatEntitlement; - if (entitlementsResponse.access_type_sku === 'free_limited_copilot') { + if (entitlementsData.access_type_sku === 'free_limited_copilot') { entitlement = ChatEntitlement.Free; - } else if (entitlementsResponse.can_signup_for_limited) { + } else if (entitlementsData.can_signup_for_limited) { entitlement = ChatEntitlement.Available; - } else if (entitlementsResponse.copilot_plan === 'individual') { + } else if (entitlementsData.copilot_plan === 'individual') { entitlement = ChatEntitlement.Pro; - } else if (entitlementsResponse.copilot_plan === 'individual_pro') { + } else if (entitlementsData.copilot_plan === 'individual_pro') { entitlement = ChatEntitlement.ProPlus; - } else if (entitlementsResponse.copilot_plan === 'business') { + } else if (entitlementsData.copilot_plan === 'business') { entitlement = ChatEntitlement.Business; - } else if (entitlementsResponse.copilot_plan === 'enterprise') { + } else if (entitlementsData.copilot_plan === 'enterprise') { entitlement = ChatEntitlement.Enterprise; - } else if (entitlementsResponse.chat_enabled) { + } else if (entitlementsData.chat_enabled) { // This should never happen as we exhaustively list the plans above. But if a new plan is added in the future older clients won't break entitlement = ChatEntitlement.Pro; } else { @@ -805,15 +650,15 @@ export class ChatEntitlementRequests extends Disposable { const entitlements: IEntitlements = { entitlement, - organisations: entitlementsResponse.organization_login_list, - quotas: this.toQuotas(entitlementsResponse), - sku: entitlementsResponse.access_type_sku + organisations: entitlementsData.organization_login_list, + quotas: this.toQuotas(entitlementsData), + sku: entitlementsData.access_type_sku }; this.logService.trace(`[chat entitlement]: resolved to ${entitlements.entitlement}, quotas: ${JSON.stringify(entitlements.quotas)}`); this.telemetryService.publicLog2('chatInstallEntitlement', { entitlement: entitlements.entitlement, - tid: entitlementsResponse.analytics_tracking_id, + tid: entitlementsData.analytics_tracking_id, sku: entitlements.sku, quotaChat: entitlements.quotas?.chat?.remaining, quotaPremiumChat: entitlements.quotas?.premiumChat?.remaining, @@ -824,42 +669,29 @@ export class ChatEntitlementRequests extends Disposable { return entitlements; } - private getEntitlementUrl(): string { - if (ChatEntitlementRequests.providerId(this.configurationService) === defaultChat.provider.enterprise.id) { - try { - const enterpriseUrl = new URL(this.configurationService.getValue(defaultChat.providerUriSetting)); - return `${enterpriseUrl.protocol}//api.${enterpriseUrl.hostname}${enterpriseUrl.port ? ':' + enterpriseUrl.port : ''}/copilot_internal/user`; - } catch (error) { - this.logService.error(error); - } - } - - return defaultChat.entitlementUrl; - } - - private toQuotas(response: IEntitlementsResponse): IQuotas { + private toQuotas(entitlementsData: IEntitlementsData): IQuotas { const quotas: Mutable = { - resetDate: response.quota_reset_date_utc ?? response.quota_reset_date ?? response.limited_user_reset_date, - resetDateHasTime: typeof response.quota_reset_date_utc === 'string', + resetDate: entitlementsData.quota_reset_date_utc ?? entitlementsData.quota_reset_date ?? entitlementsData.limited_user_reset_date, + resetDateHasTime: typeof entitlementsData.quota_reset_date_utc === 'string', }; // Legacy Free SKU Quota - if (response.monthly_quotas?.chat && typeof response.limited_user_quotas?.chat === 'number') { + if (entitlementsData.monthly_quotas?.chat && typeof entitlementsData.limited_user_quotas?.chat === 'number') { quotas.chat = { - total: response.monthly_quotas.chat, - remaining: response.limited_user_quotas.chat, - percentRemaining: Math.min(100, Math.max(0, (response.limited_user_quotas.chat / response.monthly_quotas.chat) * 100)), + total: entitlementsData.monthly_quotas.chat, + remaining: entitlementsData.limited_user_quotas.chat, + percentRemaining: Math.min(100, Math.max(0, (entitlementsData.limited_user_quotas.chat / entitlementsData.monthly_quotas.chat) * 100)), overageEnabled: false, overageCount: 0, unlimited: false }; } - if (response.monthly_quotas?.completions && typeof response.limited_user_quotas?.completions === 'number') { + if (entitlementsData.monthly_quotas?.completions && typeof entitlementsData.limited_user_quotas?.completions === 'number') { quotas.completions = { - total: response.monthly_quotas.completions, - remaining: response.limited_user_quotas.completions, - percentRemaining: Math.min(100, Math.max(0, (response.limited_user_quotas.completions / response.monthly_quotas.completions) * 100)), + total: entitlementsData.monthly_quotas.completions, + remaining: entitlementsData.limited_user_quotas.completions, + percentRemaining: Math.min(100, Math.max(0, (entitlementsData.limited_user_quotas.completions / entitlementsData.monthly_quotas.completions) * 100)), overageEnabled: false, overageCount: 0, unlimited: false @@ -867,9 +699,9 @@ export class ChatEntitlementRequests extends Disposable { } // New Quota Snapshot - if (response.quota_snapshots) { + if (entitlementsData.quota_snapshots) { for (const quotaType of ['chat', 'completions', 'premium_interactions'] as const) { - const rawQuotaSnapshot = response.quota_snapshots[quotaType]; + const rawQuotaSnapshot = entitlementsData.quota_snapshots[quotaType]; if (!rawQuotaSnapshot) { continue; } @@ -947,28 +779,33 @@ export class ChatEntitlementRequests extends Disposable { } } - async forceResolveEntitlement(sessions: AuthenticationSession[] | undefined, token = CancellationToken.None): Promise { - if (!sessions) { - sessions = await this.findMatchingProviderSession(token); + async forceResolveEntitlement(token = CancellationToken.None): Promise { + const defaultAccount = await this.defaultAccountService.refresh(); + if (!defaultAccount) { + return undefined; } - if (!sessions || sessions.length === 0) { + return this.resolveEntitlement(defaultAccount, token); + } + + async signUpFree(): Promise { + const sessions = await this.getSessions(); + if (sessions.length === 0) { return undefined; } - - return this.resolveEntitlement(sessions, token); + return this.doSignUpFree(sessions); } - async signUpFree(sessions: AuthenticationSession[]): Promise { + private async doSignUpFree(sessions: AuthenticationSession[]): Promise { const body = { restricted_telemetry: this.telemetryService.telemetryLevel === TelemetryLevel.NONE ? 'disabled' : 'enabled', public_code_suggestions: 'enabled' }; - const response = await this.request(defaultChat.entitlementSignupLimitedUrl, 'POST', body, sessions, CancellationToken.None); + const response = await this.request(defaultChatAgent.entitlementSignupLimitedUrl, 'POST', body, sessions, CancellationToken.None); if (!response) { const retry = await this.onUnknownSignUpError(localize('signUpNoResponseError', "No response received."), '[chat entitlement] sign-up: no response'); - return retry ? this.signUpFree(sessions) : { errorCode: 1 }; + return retry ? this.doSignUpFree(sessions) : { errorCode: 1 }; } if (response.res.statusCode && response.res.statusCode !== 200) { @@ -987,7 +824,7 @@ export class ChatEntitlementRequests extends Disposable { } } const retry = await this.onUnknownSignUpError(localize('signUpUnexpectedStatusError', "Unexpected status code {0}.", response.res.statusCode), `[chat entitlement] sign-up: unexpected status code ${response.res.statusCode}`); - return retry ? this.signUpFree(sessions) : { errorCode: response.res.statusCode }; + return retry ? this.doSignUpFree(sessions) : { errorCode: response.res.statusCode }; } let responseText: string | null = null; @@ -999,7 +836,7 @@ export class ChatEntitlementRequests extends Disposable { if (!responseText) { const retry = await this.onUnknownSignUpError(localize('signUpNoResponseContentsError', "Response has no contents."), '[chat entitlement] sign-up: response has no content'); - return retry ? this.signUpFree(sessions) : { errorCode: 2 }; + return retry ? this.doSignUpFree(sessions) : { errorCode: 2 }; } let parsedResult: { subscribed: boolean } | undefined = undefined; @@ -1008,7 +845,7 @@ export class ChatEntitlementRequests extends Disposable { this.logService.trace(`[chat entitlement] sign-up: response is ${responseText}`); } catch (err) { const retry = await this.onUnknownSignUpError(localize('signUpInvalidResponseError', "Invalid response contents."), `[chat entitlement] sign-up: error parsing response (${err})`); - return retry ? this.signUpFree(sessions) : { errorCode: 3 }; + return retry ? this.doSignUpFree(sessions) : { errorCode: 3 }; } // We have made it this far, so the user either did sign-up or was signed-up already. @@ -1018,6 +855,18 @@ export class ChatEntitlementRequests extends Disposable { return Boolean(parsedResult?.subscribed); } + private async getSessions(): Promise { + const defaultAccount = await this.defaultAccountService.getDefaultAccount(); + if (defaultAccount) { + const sessions = await this.authenticationService.getSessions(defaultAccount.authenticationProvider.id); + const accountSessions = sessions.filter(s => s.id === defaultAccount.sessionId); + if (accountSessions.length) { + return accountSessions; + } + } + return [...(await this.authenticationService.getSessions(this.defaultAccountService.getDefaultAccountAuthenticationProvider().id))]; + } + private async onUnknownSignUpError(detail: string, logMessage: string): Promise { this.logService.error(logMessage); @@ -1050,31 +899,25 @@ export class ChatEntitlementRequests extends Disposable { }, { label: localize('learnMore', "Learn More"), - run: () => this.openerService.open(URI.parse(defaultChat.upgradePlanUrl)) + run: () => this.openerService.open(URI.parse(defaultChatAgent.upgradePlanUrl)) } ] }); } } - async signIn(options?: { useSocialProvider?: string; additionalScopes?: readonly string[] }) { - const providerId = ChatEntitlementRequests.providerId(this.configurationService); - - const scopes = options?.additionalScopes ? distinct([...defaultChat.providerScopes[0], ...options.additionalScopes]) : defaultChat.providerScopes[0]; - const session = await this.authenticationService.createSession( - providerId, - scopes, - { - extraAuthorizeParameters: { get_started_with: 'copilot-vscode' }, - provider: options?.useSocialProvider - }); - - this.authenticationExtensionsService.updateAccountPreference(defaultChat.extensionId, providerId, session.account); - this.authenticationExtensionsService.updateAccountPreference(defaultChat.chatExtensionId, providerId, session.account); - - const entitlements = await this.forceResolveEntitlement([session]); + async signIn(options?: { useSocialProvider?: string; additionalScopes?: readonly string[] }): Promise<{ defaultAccount?: IDefaultAccount; entitlements?: IEntitlements }> { + const defaultAccount = await this.defaultAccountService.signIn({ + additionalScopes: options?.additionalScopes, + extraAuthorizeParameters: { get_started_with: 'copilot-vscode' }, + provider: options?.useSocialProvider + }); + if (!defaultAccount) { + return {}; + } - return { session, entitlements }; + const entitlements = await this.doResolveEntitlement(defaultAccount, CancellationToken.None); + return { defaultAccount, entitlements }; } override dispose(): void { diff --git a/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts index ba14adae9a955..603ad3e902ced 100644 --- a/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts +++ b/src/vs/workbench/services/extensionManagement/electron-browser/extensionGalleryManifestService.ts @@ -148,8 +148,8 @@ export class WorkbenchExtensionGalleryManifestService extends ExtensionGalleryMa } private checkAccess(account: IDefaultAccount): boolean { - this.logService.debug('[Marketplace] Checking Account SKU access for configured gallery', account.access_type_sku); - if (account.access_type_sku && this.productService.extensionsGallery?.accessSKUs?.includes(account.access_type_sku)) { + this.logService.debug('[Marketplace] Checking Account SKU access for configured gallery', account.entitlementsData?.access_type_sku); + if (account.entitlementsData?.access_type_sku && this.productService.extensionsGallery?.accessSKUs?.includes(account.entitlementsData.access_type_sku)) { this.logService.debug('[Marketplace] Account has access to configured gallery'); return true; } diff --git a/src/vs/workbench/services/policies/test/common/accountPolicyService.test.ts b/src/vs/workbench/services/policies/test/common/accountPolicyService.test.ts index a3af59e5c7756..73b8f9eff6790 100644 --- a/src/vs/workbench/services/policies/test/common/accountPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/common/accountPolicyService.test.ts @@ -4,22 +4,50 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IDefaultAccountProvider, IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { DefaultAccountService } from '../../../accounts/common/defaultAccount.js'; import { AccountPolicyService } from '../../common/accountPolicyService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; import { Registry } from '../../../../../platform/registry/common/platform.js'; import { Extensions, IConfigurationNode, IConfigurationRegistry } from '../../../../../platform/configuration/common/configurationRegistry.js'; import { DefaultConfiguration, PolicyConfiguration } from '../../../../../platform/configuration/common/configurations.js'; -import { IDefaultAccount } from '../../../../../base/common/defaultAccount.js'; +import { IDefaultAccount, IDefaultAccountAuthenticationProvider } from '../../../../../base/common/defaultAccount.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; +import { TestProductService } from '../../../../test/common/workbenchTestServices.js'; const BASE_DEFAULT_ACCOUNT: IDefaultAccount = { - enterprise: false, + authenticationProvider: { + id: 'github', + name: 'GitHub', + enterprise: false, + }, sessionId: 'abc123', + enterprise: false, }; +class DefaultAccountProvider implements IDefaultAccountProvider { + + readonly onDidChangeDefaultAccount = Event.None; + + constructor( + readonly defaultAccount: IDefaultAccount, + ) { } + + getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { + return this.defaultAccount.authenticationProvider; + } + + async refresh(): Promise { + return this.defaultAccount; + } + + async signIn(): Promise { + return null; + } +} + suite('AccountPolicyService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -53,7 +81,7 @@ suite('AccountPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - value: account => account.chat_preview_features_enabled === false ? 'policyValueB' : undefined, + value: account => account.policyData?.chat_preview_features_enabled === false ? 'policyValueB' : undefined, } }, 'setting.C': { @@ -64,7 +92,7 @@ suite('AccountPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - value: account => account.chat_preview_features_enabled === false ? JSON.stringify(['policyValueC1', 'policyValueC2']) : undefined, + value: account => account.policyData?.chat_preview_features_enabled === false ? JSON.stringify(['policyValueC1', 'policyValueC2']) : undefined, } }, 'setting.D': { @@ -75,7 +103,7 @@ suite('AccountPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - value: account => account.chat_preview_features_enabled === false ? false : undefined, + value: account => account.policyData?.chat_preview_features_enabled === false ? false : undefined, } }, 'setting.E': { @@ -93,14 +121,15 @@ suite('AccountPolicyService', () => { const defaultConfiguration = disposables.add(new DefaultConfiguration(new NullLogService())); await defaultConfiguration.initialize(); - defaultAccountService = disposables.add(new DefaultAccountService()); + defaultAccountService = disposables.add(new DefaultAccountService(TestProductService)); policyService = disposables.add(new AccountPolicyService(logService, defaultAccountService)); policyConfiguration = disposables.add(new PolicyConfiguration(defaultConfiguration, policyService, new NullLogService())); }); async function assertDefaultBehavior(defaultAccount: IDefaultAccount) { - defaultAccountService.setDefaultAccount(defaultAccount); + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(defaultAccount)); + await defaultAccountService.refresh(); await policyConfiguration.initialize(); @@ -135,13 +164,14 @@ suite('AccountPolicyService', () => { }); test('should initialize with default account and preview features enabled', async () => { - const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, chat_preview_features_enabled: true }; + const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, policyData: { chat_preview_features_enabled: true } }; await assertDefaultBehavior(defaultAccount); }); test('should initialize with default account and preview features disabled', async () => { - const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, chat_preview_features_enabled: false }; - defaultAccountService.setDefaultAccount(defaultAccount); + const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, policyData: { chat_preview_features_enabled: false } }; + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(defaultAccount)); + await defaultAccountService.refresh(); await policyConfiguration.initialize(); const actualConfigurationModel = policyConfiguration.configurationModel; diff --git a/src/vs/workbench/services/policies/test/common/multiplexPolicyService.test.ts b/src/vs/workbench/services/policies/test/common/multiplexPolicyService.test.ts index d410478e5b2e6..481e3a4e79566 100644 --- a/src/vs/workbench/services/policies/test/common/multiplexPolicyService.test.ts +++ b/src/vs/workbench/services/policies/test/common/multiplexPolicyService.test.ts @@ -4,8 +4,9 @@ *--------------------------------------------------------------------------------------------*/ import assert from 'assert'; +import { Event } from '../../../../../base/common/event.js'; import { NullLogService } from '../../../../../platform/log/common/log.js'; -import { IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; +import { IDefaultAccountProvider, IDefaultAccountService } from '../../../../../platform/defaultAccount/common/defaultAccount.js'; import { DefaultAccountService } from '../../../accounts/common/defaultAccount.js'; import { AccountPolicyService } from '../../common/accountPolicyService.js'; import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../../base/test/common/utils.js'; @@ -19,14 +20,41 @@ import { IFileService } from '../../../../../platform/files/common/files.js'; import { InMemoryFileSystemProvider } from '../../../../../platform/files/common/inMemoryFilesystemProvider.js'; import { FileService } from '../../../../../platform/files/common/fileService.js'; import { VSBuffer } from '../../../../../base/common/buffer.js'; -import { IDefaultAccount } from '../../../../../base/common/defaultAccount.js'; +import { IDefaultAccount, IDefaultAccountAuthenticationProvider } from '../../../../../base/common/defaultAccount.js'; import { PolicyCategory } from '../../../../../base/common/policy.js'; +import { TestProductService } from '../../../../test/common/workbenchTestServices.js'; const BASE_DEFAULT_ACCOUNT: IDefaultAccount = { + authenticationProvider: { + id: 'github', + name: 'GitHub', + enterprise: false, + }, enterprise: false, sessionId: 'abc123', }; +class DefaultAccountProvider implements IDefaultAccountProvider { + + readonly onDidChangeDefaultAccount = Event.None; + + constructor( + readonly defaultAccount: IDefaultAccount, + ) { } + + getDefaultAccountAuthenticationProvider(): IDefaultAccountAuthenticationProvider { + return this.defaultAccount.authenticationProvider; + } + + async refresh(): Promise { + return this.defaultAccount; + } + + async signIn(): Promise { + return null; + } +} + suite('MultiplexPolicyService', () => { const disposables = ensureNoDisposablesAreLeakedInTestSuite(); @@ -62,7 +90,7 @@ suite('MultiplexPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - value: account => account.chat_preview_features_enabled === false ? 'policyValueB' : undefined, + value: account => account.policyData?.chat_preview_features_enabled === false ? 'policyValueB' : undefined, } }, 'setting.C': { @@ -73,7 +101,7 @@ suite('MultiplexPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - value: account => account.chat_preview_features_enabled === false ? JSON.stringify(['policyValueC1', 'policyValueC2']) : undefined, + value: account => account.policyData?.chat_preview_features_enabled === false ? JSON.stringify(['policyValueC1', 'policyValueC2']) : undefined, } }, 'setting.D': { @@ -84,7 +112,7 @@ suite('MultiplexPolicyService', () => { category: PolicyCategory.Extensions, minimumVersion: '1.0.0', localization: { description: { key: '', value: '' } }, - value: account => account.chat_preview_features_enabled === false ? false : undefined, + value: account => account.policyData?.chat_preview_features_enabled === false ? false : undefined, } }, 'setting.E': { @@ -106,7 +134,7 @@ suite('MultiplexPolicyService', () => { const diskFileSystemProvider = disposables.add(new InMemoryFileSystemProvider()); disposables.add(fileService.registerProvider(policyFile.scheme, diskFileSystemProvider)); - defaultAccountService = disposables.add(new DefaultAccountService()); + defaultAccountService = disposables.add(new DefaultAccountService(TestProductService)); policyService = disposables.add(new MultiplexPolicyService([ disposables.add(new FilePolicyService(policyFile, fileService, new NullLogService())), disposables.add(new AccountPolicyService(logService, defaultAccountService)), @@ -115,8 +143,6 @@ suite('MultiplexPolicyService', () => { }); async function clear() { - // Reset - defaultAccountService.setDefaultAccount({ ...BASE_DEFAULT_ACCOUNT }); await fileService.writeFile(policyFile, VSBuffer.fromString( JSON.stringify({}) @@ -161,7 +187,8 @@ suite('MultiplexPolicyService', () => { await clear(); const defaultAccount = { ...BASE_DEFAULT_ACCOUNT }; - defaultAccountService.setDefaultAccount(defaultAccount); + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(defaultAccount)); + await defaultAccountService.refresh(); await fileService.writeFile(policyFile, VSBuffer.fromString( @@ -201,8 +228,9 @@ suite('MultiplexPolicyService', () => { test('policy from default account only', async () => { await clear(); - const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, chat_preview_features_enabled: false }; - defaultAccountService.setDefaultAccount(defaultAccount); + const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, policyData: { chat_preview_features_enabled: false } }; + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(defaultAccount)); + await defaultAccountService.refresh(); await fileService.writeFile(policyFile, VSBuffer.fromString( @@ -241,8 +269,9 @@ suite('MultiplexPolicyService', () => { test('policy from file and default account', async () => { await clear(); - const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, chat_preview_features_enabled: false }; - defaultAccountService.setDefaultAccount(defaultAccount); + const defaultAccount = { ...BASE_DEFAULT_ACCOUNT, policyData: { chat_preview_features_enabled: false } }; + defaultAccountService.setDefaultAccountProvider(new DefaultAccountProvider(defaultAccount)); + await defaultAccountService.refresh(); await fileService.writeFile(policyFile, VSBuffer.fromString( diff --git a/src/vs/workbench/services/preferences/browser/preferencesService.ts b/src/vs/workbench/services/preferences/browser/preferencesService.ts index 93914590d96e5..4557cd63aac42 100644 --- a/src/vs/workbench/services/preferences/browser/preferencesService.ts +++ b/src/vs/workbench/services/preferences/browser/preferencesService.ts @@ -253,9 +253,9 @@ export class PreferencesService extends Disposable implements IPreferencesServic const idMatch = query.match(/^@id:(.+)$/); let key: string | undefined; if (idMatch) { - key = idMatch[1]; - } else if (Registry.as(Extensions.Configuration).getConfigurationProperties()[query]) { - key = query; + key = idMatch[1].trim(); + } else if (Registry.as(Extensions.Configuration).getConfigurationProperties()[query.trim()]) { + key = query.trim(); } options.query = undefined; if (key) {