diff --git a/apps/backend/core/client.py b/apps/backend/core/client.py index a8f4a318ba..ebd4a55582 100644 --- a/apps/backend/core/client.py +++ b/apps/backend/core/client.py @@ -709,6 +709,7 @@ def create_client( (see security.py for ALLOWED_COMMANDS) 4. Tool filtering - Each agent type only sees relevant tools (prevents misuse) """ + # Get OAuth token - Claude CLI handles token lifecycle internally oauth_token = require_auth_token() # Validate token is not encrypted before passing to SDK diff --git a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts index 3cb23d30d7..c20fb79dad 100644 --- a/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts +++ b/apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts @@ -11,6 +11,7 @@ import { } from "../../shared/constants/phase-protocol"; import type { SDKRateLimitInfo, + AuthFailureInfo, Task, TaskStatus, Project, @@ -26,6 +27,7 @@ import { persistPlanStatusSync, getPlanPath } from "./task/plan-file-utils"; import { findTaskWorktree } from "../worktree-paths"; import { findTaskAndProject } from "./task/shared"; import { safeSendToRenderer } from "./utils"; +import { getClaudeProfileManager } from "../claude-profile-manager"; /** * Validates status transitions to prevent invalid state changes. @@ -133,6 +135,34 @@ export function registerAgenteventsHandlers( safeSendToRenderer(getMainWindow, IPC_CHANNELS.CLAUDE_SDK_RATE_LIMIT, rateLimitInfo); }); + // Handle auth failure events (401 errors requiring re-authentication) + agentManager.on("auth-failure", (taskId: string, authFailure: { + profileId?: string; + failureType?: 'missing' | 'invalid' | 'expired' | 'unknown'; + message?: string; + originalError?: string; + }) => { + console.warn(`[AgentEvents] Auth failure detected for task ${taskId}:`, authFailure); + + // Get profile name for display + const profileManager = getClaudeProfileManager(); + const profile = authFailure.profileId + ? profileManager.getProfile(authFailure.profileId) + : profileManager.getActiveProfile(); + + const authFailureInfo: AuthFailureInfo = { + profileId: authFailure.profileId || profile?.id || 'unknown', + profileName: profile?.name, + failureType: authFailure.failureType || 'unknown', + message: authFailure.message || 'Authentication failed. Please re-authenticate.', + originalError: authFailure.originalError, + taskId, + detectedAt: new Date(), + }; + + safeSendToRenderer(getMainWindow, IPC_CHANNELS.CLAUDE_AUTH_FAILURE, authFailureInfo); + }); + agentManager.on("exit", (taskId: string, code: number | null, processType: ProcessType) => { // Get project info early for multi-project filtering (issue #723) const { project: exitProject } = findTaskAndProject(taskId); diff --git a/apps/frontend/src/preload/api/terminal-api.ts b/apps/frontend/src/preload/api/terminal-api.ts index 7efd414aae..28bb25e1d1 100644 --- a/apps/frontend/src/preload/api/terminal-api.ts +++ b/apps/frontend/src/preload/api/terminal-api.ts @@ -113,6 +113,7 @@ export interface TerminalAPI { fetchClaudeUsage: (terminalId: string) => Promise; getBestAvailableProfile: (excludeProfileId?: string) => Promise>; onSDKRateLimit: (callback: (info: import('../../shared/types').SDKRateLimitInfo) => void) => () => void; + onAuthFailure: (callback: (info: import('../../shared/types').AuthFailureInfo) => void) => () => void; retryWithProfile: (request: import('../../shared/types').RetryWithProfileRequest) => Promise; // Usage Monitoring (Proactive Account Switching) @@ -485,6 +486,21 @@ export const createTerminalAPI = (): TerminalAPI => ({ }; }, + onAuthFailure: ( + callback: (info: import('../../shared/types').AuthFailureInfo) => void + ): (() => void) => { + const handler = ( + _event: Electron.IpcRendererEvent, + info: import('../../shared/types').AuthFailureInfo + ): void => { + callback(info); + }; + ipcRenderer.on(IPC_CHANNELS.CLAUDE_AUTH_FAILURE, handler); + return () => { + ipcRenderer.removeListener(IPC_CHANNELS.CLAUDE_AUTH_FAILURE, handler); + }; + }, + retryWithProfile: (request: import('../../shared/types').RetryWithProfileRequest): Promise => ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE, request), diff --git a/apps/frontend/src/renderer/App.tsx b/apps/frontend/src/renderer/App.tsx index 4698ef8ee5..9ffcded419 100644 --- a/apps/frontend/src/renderer/App.tsx +++ b/apps/frontend/src/renderer/App.tsx @@ -48,6 +48,7 @@ import { AgentTools } from './components/AgentTools'; import { WelcomeScreen } from './components/WelcomeScreen'; import { RateLimitModal } from './components/RateLimitModal'; import { SDKRateLimitModal } from './components/SDKRateLimitModal'; +import { AuthFailureModal } from './components/AuthFailureModal'; import { OnboardingWizard } from './components/onboarding'; import { AppUpdateNotification } from './components/AppUpdateNotification'; import { ProactiveSwapListener } from './components/ProactiveSwapListener'; @@ -1075,6 +1076,9 @@ export function App() { {/* SDK Rate Limit Modal - shows when SDK/CLI operations hit limits (changelog, tasks, etc.) */} + {/* Auth Failure Modal - shows when Claude CLI encounters 401/auth errors */} + setIsSettingsDialogOpen(true)} /> + {/* Onboarding Wizard - shows on first launch when onboardingCompleted is false */} void; +} + +/** + * Modal displayed when Claude CLI encounters an authentication failure (401 error). + * Prompts the user to re-authenticate via Settings > Claude Profiles. + */ +export function AuthFailureModal({ onOpenSettings }: AuthFailureModalProps) { + const { isModalOpen, authFailureInfo, hideAuthFailureModal, clearAuthFailure } = useAuthFailureStore(); + const { t } = useTranslation('common'); + + if (!authFailureInfo) return null; + + const profileName = authFailureInfo.profileName || t('auth.failure.unknownProfile', 'Unknown Profile'); + + // Get user-friendly message for the auth failure type + const getFailureMessage = () => { + switch (authFailureInfo.failureType) { + case 'expired': + return t('auth.failure.tokenExpired', 'Your authentication token has expired.'); + case 'invalid': + return t('auth.failure.tokenInvalid', 'Your authentication token is invalid.'); + case 'missing': + return t('auth.failure.tokenMissing', 'No authentication token found.'); + default: + return t('auth.failure.authFailed', 'Authentication failed.'); + } + }; + + const failureMessage = getFailureMessage(); + + const handleGoToSettings = () => { + hideAuthFailureModal(); + onOpenSettings?.(); + }; + + const handleDismiss = () => { + clearAuthFailure(); + }; + + return ( + !open && hideAuthFailureModal()}> + + +
+
+ +
+
+ + {t('auth.failure.title', 'Authentication Required')} + + + {t('auth.failure.profileLabel', 'Profile')}: {profileName} + +
+
+
+ +
+

+ {failureMessage} +

+

+ {t('auth.failure.description', 'Please re-authenticate your Claude profile to continue using Auto Claude.')} +

+ + {authFailureInfo.taskId && ( +
+

+ {t('auth.failure.taskAffected', 'Task affected')}: {authFailureInfo.taskId} +

+
+ )} + + {authFailureInfo.originalError && ( +
+ + {t('auth.failure.technicalDetails', 'Technical details')} + +
+                {authFailureInfo.originalError}
+              
+
+ )} +
+ + + + + +
+
+ ); +} diff --git a/apps/frontend/src/renderer/hooks/useIpc.ts b/apps/frontend/src/renderer/hooks/useIpc.ts index daba76573f..8bbeaeaaf9 100644 --- a/apps/frontend/src/renderer/hooks/useIpc.ts +++ b/apps/frontend/src/renderer/hooks/useIpc.ts @@ -3,8 +3,9 @@ import { unstable_batchedUpdates } from 'react-dom'; import { useTaskStore } from '../stores/task-store'; import { useRoadmapStore } from '../stores/roadmap-store'; import { useRateLimitStore } from '../stores/rate-limit-store'; +import { useAuthFailureStore } from '../stores/auth-failure-store'; import { useProjectStore } from '../stores/project-store'; -import type { ImplementationPlan, TaskStatus, RoadmapGenerationStatus, Roadmap, ExecutionProgress, RateLimitInfo, SDKRateLimitInfo } from '../../shared/types'; +import type { ImplementationPlan, TaskStatus, RoadmapGenerationStatus, Roadmap, ExecutionProgress, RateLimitInfo, SDKRateLimitInfo, AuthFailureInfo } from '../../shared/types'; /** * Batched update queue for IPC events. @@ -333,6 +334,20 @@ export function useIpcListeners(): void { } ); + // Auth failure listener (401 errors requiring re-authentication) + const showAuthFailureModal = useAuthFailureStore.getState().showAuthFailureModal; + const cleanupAuthFailure = window.electronAPI.onAuthFailure( + (info: AuthFailureInfo) => { + // Convert detectedAt string to Date if needed + showAuthFailureModal({ + ...info, + detectedAt: typeof info.detectedAt === 'string' + ? new Date(info.detectedAt) + : info.detectedAt + }); + } + ); + // Cleanup on unmount return () => { // Flush any pending batched updates before cleanup @@ -352,6 +367,7 @@ export function useIpcListeners(): void { cleanupRoadmapStopped(); cleanupRateLimit(); cleanupSDKRateLimit(); + cleanupAuthFailure(); }; }, [updateTaskFromPlan, updateTaskStatus, updateExecutionProgress, appendLog, batchAppendLogs, setError]); } diff --git a/apps/frontend/src/renderer/lib/mocks/claude-profile-mock.ts b/apps/frontend/src/renderer/lib/mocks/claude-profile-mock.ts index 104388d54b..c266395c52 100644 --- a/apps/frontend/src/renderer/lib/mocks/claude-profile-mock.ts +++ b/apps/frontend/src/renderer/lib/mocks/claude-profile-mock.ts @@ -58,6 +58,8 @@ export const claudeProfileMock = { onSDKRateLimit: () => () => {}, + onAuthFailure: () => () => {}, + retryWithProfile: async () => ({ success: true }), // Usage Monitoring (Proactive Account Switching) diff --git a/apps/frontend/src/renderer/stores/auth-failure-store.ts b/apps/frontend/src/renderer/stores/auth-failure-store.ts new file mode 100644 index 0000000000..62cf171509 --- /dev/null +++ b/apps/frontend/src/renderer/stores/auth-failure-store.ts @@ -0,0 +1,44 @@ +import { create } from 'zustand'; +import type { AuthFailureInfo } from '../../shared/types'; + +interface AuthFailureState { + // Auth failure modal state + isModalOpen: boolean; + authFailureInfo: AuthFailureInfo | null; + + // TODO: Use hasPendingAuthFailure to show a badge/indicator in the sidebar + // when there's an unresolved auth failure (e.g., red dot on Settings icon) + hasPendingAuthFailure: boolean; + + // Actions + showAuthFailureModal: (info: AuthFailureInfo) => void; + hideAuthFailureModal: () => void; + clearAuthFailure: () => void; +} + +export const useAuthFailureStore = create((set) => ({ + isModalOpen: false, + authFailureInfo: null, + hasPendingAuthFailure: false, + + showAuthFailureModal: (info: AuthFailureInfo) => { + set({ + isModalOpen: true, + authFailureInfo: info, + hasPendingAuthFailure: true, + }); + }, + + hideAuthFailureModal: () => { + // Keep the failure info when closing so user can see it again + set({ isModalOpen: false }); + }, + + clearAuthFailure: () => { + set({ + isModalOpen: false, + authFailureInfo: null, + hasPendingAuthFailure: false, + }); + }, +})); diff --git a/apps/frontend/src/shared/constants/ipc.ts b/apps/frontend/src/shared/constants/ipc.ts index e7bdf73a78..6b538ae8bd 100644 --- a/apps/frontend/src/shared/constants/ipc.ts +++ b/apps/frontend/src/shared/constants/ipc.ts @@ -121,6 +121,8 @@ export const IPC_CHANNELS = { // SDK/CLI rate limit event (for non-terminal Claude invocations) CLAUDE_SDK_RATE_LIMIT: 'claude:sdkRateLimit', + // Auth failure event (401 errors requiring re-authentication) + CLAUDE_AUTH_FAILURE: 'claude:authFailure', // Retry a rate-limited operation with a different profile CLAUDE_RETRY_WITH_PROFILE: 'claude:retryWithProfile', diff --git a/apps/frontend/src/shared/i18n/locales/en/common.json b/apps/frontend/src/shared/i18n/locales/en/common.json index ff8889b143..ae2881fb6a 100644 --- a/apps/frontend/src/shared/i18n/locales/en/common.json +++ b/apps/frontend/src/shared/i18n/locales/en/common.json @@ -449,5 +449,20 @@ "step2": "Find the profile in the Claude Accounts section", "step3": "Click \"Authenticate\" to complete login", "footer": "The account will be available once you complete authentication." + }, + "auth": { + "failure": { + "title": "Authentication Required", + "profileLabel": "Profile", + "unknownProfile": "Unknown Profile", + "tokenExpired": "Your authentication token has expired.", + "tokenInvalid": "Your authentication token is invalid.", + "tokenMissing": "No authentication token found.", + "authFailed": "Authentication failed.", + "description": "Please re-authenticate your Claude profile to continue using Auto Claude.", + "taskAffected": "Task affected", + "technicalDetails": "Technical details", + "goToSettings": "Go to Settings" + } } } diff --git a/apps/frontend/src/shared/i18n/locales/fr/common.json b/apps/frontend/src/shared/i18n/locales/fr/common.json index 669e792b69..a79e78370e 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/common.json +++ b/apps/frontend/src/shared/i18n/locales/fr/common.json @@ -449,5 +449,20 @@ "step2": "Trouvez le profil dans la section Comptes Claude", "step3": "Cliquez sur « Authentifier » pour terminer la connexion", "footer": "Le compte sera disponible une fois l'authentification terminée." + }, + "auth": { + "failure": { + "title": "Authentification requise", + "profileLabel": "Profil", + "unknownProfile": "Profil inconnu", + "tokenExpired": "Votre jeton d'authentification a expiré.", + "tokenInvalid": "Votre jeton d'authentification est invalide.", + "tokenMissing": "Aucun jeton d'authentification trouvé.", + "authFailed": "Échec de l'authentification.", + "description": "Veuillez vous ré-authentifier pour continuer à utiliser Auto Claude.", + "taskAffected": "Tâche affectée", + "technicalDetails": "Détails techniques", + "goToSettings": "Aller aux paramètres" + } } } diff --git a/apps/frontend/src/shared/types/ipc.ts b/apps/frontend/src/shared/types/ipc.ts index 884ce28198..689422ad9b 100644 --- a/apps/frontend/src/shared/types/ipc.ts +++ b/apps/frontend/src/shared/types/ipc.ts @@ -53,6 +53,7 @@ import type { SessionDateRestoreResult, RateLimitInfo, SDKRateLimitInfo, + AuthFailureInfo, RetryWithProfileRequest, CreateTerminalWorktreeRequest, TerminalWorktreeConfig, @@ -297,6 +298,8 @@ export interface ElectronAPI { getBestAvailableProfile: (excludeProfileId?: string) => Promise>; /** Listen for SDK/CLI rate limit events (non-terminal) */ onSDKRateLimit: (callback: (info: SDKRateLimitInfo) => void) => () => void; + /** Listen for auth failure events (401 errors requiring re-authentication) */ + onAuthFailure: (callback: (info: AuthFailureInfo) => void) => () => void; /** Retry a rate-limited operation with a different profile */ retryWithProfile: (request: RetryWithProfileRequest) => Promise; diff --git a/apps/frontend/src/shared/types/terminal.ts b/apps/frontend/src/shared/types/terminal.ts index c5461d8fb6..20a50d1de7 100644 --- a/apps/frontend/src/shared/types/terminal.ts +++ b/apps/frontend/src/shared/types/terminal.ts @@ -135,6 +135,28 @@ export interface SDKRateLimitInfo { swapReason?: 'proactive' | 'reactive'; } +/** + * Authentication failure information for SDK/CLI operations. + * Emitted when Claude CLI encounters a 401 or other auth error, + * indicating the token needs to be refreshed via re-authentication. + */ +export interface AuthFailureInfo { + /** The profile ID that failed to authenticate */ + profileId: string; + /** The profile name for display */ + profileName?: string; + /** Type of auth failure */ + failureType: 'missing' | 'invalid' | 'expired' | 'unknown'; + /** User-friendly message describing the failure */ + message: string; + /** Original error message from the process output */ + originalError?: string; + /** Task ID if applicable (for task-related auth failures) */ + taskId?: string; + /** When detected (Note: serialized as ISO string over IPC) */ + detectedAt: Date; +} + /** * Request to retry a rate-limited operation with a different profile */