Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/rovo-dev/api/extensionApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
87 changes: 87 additions & 0 deletions src/rovo-dev/rovoDevChatProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<anonymous> (/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,
}),
);
});
});
});
18 changes: 18 additions & 0 deletions src/rovo-dev/rovoDevChatProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { v4 } from 'uuid';
import { ExtensionApi } from './api/extensionApi';
import {
RovoDevApiClient,
RovoDevApiError,
RovoDevChatRequest,
RovoDevChatRequestContext,
RovoDevChatRequestContextFileEntry,
Expand Down Expand Up @@ -48,6 +49,7 @@ export class RovoDevChatProvider {
private _currentPrompt: RovoDevPrompt | undefined;
private _rovoDevApiClient: RovoDevApiClient | undefined;
private _webView: TypedWebview<RovoDevProviderMessage, RovoDevViewResponse> | undefined;
private _onUnauthorizedCallback: (() => Promise<void>) | undefined;

private _replayInProgress = false;
private _lastMessageSentTime: number | undefined;
Expand Down Expand Up @@ -98,6 +100,10 @@ export class RovoDevChatProvider {
this._webView = webView;
}

public setOnUnauthorizedCallback(callback: (() => Promise<void>) | undefined) {
this._onUnauthorizedCallback = callback;
}

public async setReady(rovoDevApiClient: RovoDevApiClient) {
this._rovoDevApiClient = rovoDevApiClient;

Expand Down Expand Up @@ -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' });
}
Expand Down
27 changes: 27 additions & 0 deletions src/rovo-dev/rovoDevWebviewProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions src/rovo-dev/rovoDevWebviewProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,26 @@ export class RovoDevWebviewProvider extends Disposable implements WebviewViewPro
}
}

private async sendExpiredRovoDevCredentials(): Promise<void> {
if (!this._webView) {
return;
}

try {
const expiredCreds = await this.extensionApi.auth.getExpiredRovoDevCredentials();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You could re-use getRovoDevAuthInfo and use just the parts you need


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<void> {
const webview = this._webView!;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,23 @@ export const DisabledMessage: React.FC<{
}

if (currentState.state === 'Disabled' && currentState.subState === 'UnauthorizedAuth') {
if (features?.dedicatedRovoDevAuth) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you make a jira ticket in the next sprint to get rid of this feature flag from the code base?

// Show inline login form with expired credentials pre-filled
return (
<div style={loginFormContainerStyles}>
<div style={{ marginBottom: '12px' }}>
Your API token has expired. Please sign in again with a new API token.
</div>
<RovoDevLoginForm
onSubmit={(host, email, apiToken) => {
onRovoDevAuthSubmit(host, email, apiToken);
}}
credentialHints={credentialHints}
/>
</div>
);
}

return (
<div style={messageOuterStyles}>
<div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ export const RovoDevLoginForm: React.FC<{
const emailSelectRef = React.useRef<any>(null);
const apiTokenInputRef = React.useRef<HTMLInputElement>(null);

// Pre-fill host and email if there's exactly one credential hint (expired credentials case)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oooo nice!

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 => {
Expand Down
Loading