diff --git a/src/rovo-dev/api/extensionApi.ts b/src/rovo-dev/api/extensionApi.ts index bffcfb8fc..55effaba0 100644 --- a/src/rovo-dev/api/extensionApi.ts +++ b/src/rovo-dev/api/extensionApi.ts @@ -196,6 +196,33 @@ export class ExtensionApi { // Remove duplicates by creating a unique key return Array.from(new Map(credentials.map((c) => [`${c.host}-${c.email}`, c])).values()); }, + + /** + * Get expired RovoDev credential info (host + email) from stored credentials. + * Does NOT include the API token for security reasons. + * Used to pre-fill the login form when credentials have expired. + * + * @returns Object with host and email if expired credentials exist, undefined otherwise + */ + getExpiredRovoDevCredentials: async (): Promise<{ host: string; email: string } | undefined> => { + try { + const rovoDevAuth = await Container.credentialManager.getRovoDevAuthInfo(); + if (!rovoDevAuth || !rovoDevAuth.user?.email) { + return undefined; + } + // RovoDev auth info includes extra fields like 'host' and 'cloudId' + const rovoDevAuthWithHost = rovoDevAuth as any; + if (rovoDevAuthWithHost.host) { + return { + host: rovoDevAuthWithHost.host, + email: rovoDevAuth.user.email, + }; + } + return undefined; + } catch { + return undefined; + } + }, }; commands = { diff --git a/src/rovo-dev/rovoDevChatProvider.test.ts b/src/rovo-dev/rovoDevChatProvider.test.ts index 311adc368..bbd2a21d2 100644 --- a/src/rovo-dev/rovoDevChatProvider.test.ts +++ b/src/rovo-dev/rovoDevChatProvider.test.ts @@ -907,5 +907,92 @@ describe('RovoDevChatProvider', () => { expect.any(AbortSignal), ); }); + + it('should trigger unauthorized callback on 401 error', async () => { + const mockUnauthorizedCallback = jest.fn(); + chatProvider.setOnUnauthorizedCallback(mockUnauthorizedCallback); + + const mockPrompt: RovoDevPrompt = { + text: 'test prompt', + enable_deep_plan: false, + context: [], + }; + + // Import RovoDevApiError properly + const { RovoDevApiError } = await import('./client/rovoDevApiClient'); + const unauthorizedError = new RovoDevApiError('Unauthorized', 401, undefined); + mockApiClient.chat.mockRejectedValue(unauthorizedError); + + await chatProvider.executeChat(mockPrompt, []); + + expect(mockUnauthorizedCallback).toHaveBeenCalled(); + }); + + it('should trigger unauthorized callback on 403 error', async () => { + const mockUnauthorizedCallback = jest.fn(); + chatProvider.setOnUnauthorizedCallback(mockUnauthorizedCallback); + + const mockPrompt: RovoDevPrompt = { + text: 'test prompt', + enable_deep_plan: false, + context: [], + }; + + // Import RovoDevApiError properly + const { RovoDevApiError } = await import('./client/rovoDevApiClient'); + const forbiddenError = new RovoDevApiError('Forbidden', 403, undefined); + mockApiClient.chat.mockRejectedValue(forbiddenError); + + await chatProvider.executeChat(mockPrompt, []); + + expect(mockUnauthorizedCallback).toHaveBeenCalled(); + }); + + it('should trigger unauthorized callback when stack trace contains UnauthorizedError', async () => { + const mockUnauthorizedCallback = jest.fn(); + chatProvider.setOnUnauthorizedCallback(mockUnauthorizedCallback); + + const mockPrompt: RovoDevPrompt = { + text: 'test prompt', + enable_deep_plan: false, + context: [], + }; + + // Create an error with "UnauthorizedError" in the stack trace + const errorWithUnauthorizedInStack = new Error('Some error message'); + errorWithUnauthorizedInStack.stack = `Error: Some error message + at Object. (/path/to/file.ts:123:45) + at UnauthorizedError: Token expired + at processError (/path/to/process.ts:67:89)`; + mockApiClient.chat.mockRejectedValue(errorWithUnauthorizedInStack); + + await chatProvider.executeChat(mockPrompt, []); + + expect(mockUnauthorizedCallback).toHaveBeenCalled(); + }); + + it('should not trigger unauthorized callback on other errors', async () => { + const mockUnauthorizedCallback = jest.fn(); + chatProvider.setOnUnauthorizedCallback(mockUnauthorizedCallback); + + const mockPrompt: RovoDevPrompt = { + text: 'test prompt', + enable_deep_plan: false, + context: [], + }; + + const genericError = new Error('Generic error'); + mockApiClient.chat.mockRejectedValue(genericError); + + await chatProvider.executeChat(mockPrompt, []); + + expect(mockUnauthorizedCallback).not.toHaveBeenCalled(); + // Should still show error dialog + expect(mockWebview.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ + type: RovoDevProviderMessageType.ShowDialog, + }), + ); + }); }); }); diff --git a/src/rovo-dev/rovoDevChatProvider.ts b/src/rovo-dev/rovoDevChatProvider.ts index 7aca5d75e..8916dddc8 100644 --- a/src/rovo-dev/rovoDevChatProvider.ts +++ b/src/rovo-dev/rovoDevChatProvider.ts @@ -4,6 +4,7 @@ import { v4 } from 'uuid'; import { ExtensionApi } from './api/extensionApi'; import { RovoDevApiClient, + RovoDevApiError, RovoDevChatRequest, RovoDevChatRequestContext, RovoDevChatRequestContextFileEntry, @@ -48,6 +49,7 @@ export class RovoDevChatProvider { private _currentPrompt: RovoDevPrompt | undefined; private _rovoDevApiClient: RovoDevApiClient | undefined; private _webView: TypedWebview | undefined; + private _onUnauthorizedCallback: (() => Promise) | undefined; private _replayInProgress = false; private _lastMessageSentTime: number | undefined; @@ -98,6 +100,10 @@ export class RovoDevChatProvider { this._webView = webView; } + public setOnUnauthorizedCallback(callback: (() => Promise) | undefined) { + this._onUnauthorizedCallback = callback; + } + public async setReady(rovoDevApiClient: RovoDevApiClient) { this._rovoDevApiClient = rovoDevApiClient; @@ -712,6 +718,18 @@ export class RovoDevChatProvider { RovoDevLogger.info('Rovo Dev API request aborted by user'); return; } + // Check if this is a 401 or 403 error indicating expired/invalid credentials + // Also check if the stack trace contains "UnauthorizedError" + const isUnauthorizedError = + (error instanceof RovoDevApiError && (error.httpStatus === 401 || error.httpStatus === 403)) || + (error instanceof Error && error.stack?.includes('UnauthorizedError')); + if (isUnauthorizedError) { + RovoDevLogger.info('Detected unauthorized error - triggering login UI'); + if (this._onUnauthorizedCallback) { + await this._onUnauthorizedCallback(); + return; + } + } // the error is retriable only when it happens during the streaming of a 'chat' response await this.processError(error, { isRetriable: sourceApi === 'chat' }); } diff --git a/src/rovo-dev/rovoDevWebviewProvider.test.ts b/src/rovo-dev/rovoDevWebviewProvider.test.ts index df6dc3df7..cd8f67395 100644 --- a/src/rovo-dev/rovoDevWebviewProvider.test.ts +++ b/src/rovo-dev/rovoDevWebviewProvider.test.ts @@ -670,6 +670,33 @@ describe('RovoDevWebviewProvider - Business Logic', () => { expect(getProcessTerminatedMessage(undefined)).toBe('Please start a new chat session to continue.'); }); + it('should detect UnauthorizedError in stderr and trigger login UI', () => { + const handleProcessTermination = (stderr?: string) => { + // Check if this is an unauthorized error (expired/invalid credentials) + if (stderr && stderr.includes('UnauthorizedError')) { + return { action: 'showLoginUI', reason: 'UnauthorizedAuth' }; + } + return { action: 'showTerminationError', reason: 'ProcessTerminated' }; + }; + + expect(handleProcessTermination('Error: UnauthorizedError: Token expired')).toEqual({ + action: 'showLoginUI', + reason: 'UnauthorizedAuth', + }); + expect(handleProcessTermination('Some error\n at UnauthorizedError\n at process.ts:123')).toEqual({ + action: 'showLoginUI', + reason: 'UnauthorizedAuth', + }); + expect(handleProcessTermination('Network error')).toEqual({ + action: 'showTerminationError', + reason: 'ProcessTerminated', + }); + expect(handleProcessTermination()).toEqual({ + action: 'showTerminationError', + reason: 'ProcessTerminated', + }); + }); + it('should handle process failed to initialize messages', () => { const getProcessFailedMessage = (errorMessage?: string) => { return errorMessage diff --git a/src/rovo-dev/rovoDevWebviewProvider.ts b/src/rovo-dev/rovoDevWebviewProvider.ts index 7119ed61a..7df73a07a 100644 --- a/src/rovo-dev/rovoDevWebviewProvider.ts +++ b/src/rovo-dev/rovoDevWebviewProvider.ts @@ -702,6 +702,26 @@ export class RovoDevWebviewProvider extends Disposable implements WebviewViewPro } } + private async sendExpiredRovoDevCredentials(): Promise { + if (!this._webView) { + return; + } + + try { + const expiredCreds = await this.extensionApi.auth.getExpiredRovoDevCredentials(); + + if (expiredCreds) { + await this._webView.postMessage({ + type: RovoDevProviderMessageType.SetExistingJiraCredentials, + credentials: [expiredCreds], + }); + } + } catch (error) { + // Silently fail - pre-filling is a nice-to-have feature + RovoDevLogger.error(error, 'Failed to fetch expired RovoDev credentials for pre-fill'); + } + } + public async executeNewSession(): Promise { const webview = this._webView!; @@ -1496,6 +1516,11 @@ export class RovoDevWebviewProvider extends Disposable implements WebviewViewPro this.setRovoDevTerminated(); + // If the reason is UnauthorizedAuth, send expired credentials for pre-filling the form + if (reason === 'UnauthorizedAuth') { + await this.sendExpiredRovoDevCredentials(); + } + const webView = this._webView!; await webView.postMessage({ type: RovoDevProviderMessageType.RovoDevDisabled, @@ -1570,6 +1595,13 @@ export class RovoDevWebviewProvider extends Disposable implements WebviewViewPro if (this._isProviderDisabled) { return; } + + // Check if this is an unauthorized error (expired/invalid credentials) + if (stderr && stderr.includes('UnauthorizedError')) { + await this.signalRovoDevDisabled('UnauthorizedAuth'); + return; + } + this._isProviderDisabled = true; this.setRovoDevTerminated(); diff --git a/src/rovo-dev/ui/landing-page/disabled-messages/DisabledMessage.tsx b/src/rovo-dev/ui/landing-page/disabled-messages/DisabledMessage.tsx index e25af58d5..8f1f3dd70 100644 --- a/src/rovo-dev/ui/landing-page/disabled-messages/DisabledMessage.tsx +++ b/src/rovo-dev/ui/landing-page/disabled-messages/DisabledMessage.tsx @@ -64,6 +64,23 @@ export const DisabledMessage: React.FC<{ } if (currentState.state === 'Disabled' && currentState.subState === 'UnauthorizedAuth') { + if (features?.dedicatedRovoDevAuth) { + // Show inline login form with expired credentials pre-filled + return ( +
+
+ Your API token has expired. Please sign in again with a new API token. +
+ { + onRovoDevAuthSubmit(host, email, apiToken); + }} + credentialHints={credentialHints} + /> +
+ ); + } + return (
diff --git a/src/rovo-dev/ui/landing-page/disabled-messages/RovoDevLoginForm.tsx b/src/rovo-dev/ui/landing-page/disabled-messages/RovoDevLoginForm.tsx index 7efd4c812..733a6c7d5 100644 --- a/src/rovo-dev/ui/landing-page/disabled-messages/RovoDevLoginForm.tsx +++ b/src/rovo-dev/ui/landing-page/disabled-messages/RovoDevLoginForm.tsx @@ -79,6 +79,15 @@ export const RovoDevLoginForm: React.FC<{ const emailSelectRef = React.useRef(null); const apiTokenInputRef = React.useRef(null); + // Pre-fill host and email if there's exactly one credential hint (expired credentials case) + React.useEffect(() => { + if (credentialHints.length === 1 && !host && !email) { + const hint = credentialHints[0]; + setHost(hint.host); + setEmail(hint.email); + } + }, [credentialHints, host, email]); + // Listen for validation events from the extension React.useEffect(() => { const messageHandler = (event: MessageEvent): void => {