Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions apps/backend/core/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions apps/frontend/src/main/ipc-handlers/agent-events-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "../../shared/constants/phase-protocol";
import type {
SDKRateLimitInfo,
AuthFailureInfo,
Task,
TaskStatus,
Project,
Expand Down Expand Up @@ -133,6 +134,35 @@ 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 { getClaudeProfileManager } = require("../claude-profile-manager");
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

Using require inside a TypeScript file is generally discouraged in favor of import statements. While it might work due to transpilation, using import provides better static analysis, type checking, and aligns with modern TypeScript practices.

import { getClaudeProfileManager } from "../claude-profile-manager";

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);
Expand Down
16 changes: 16 additions & 0 deletions apps/frontend/src/preload/api/terminal-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export interface TerminalAPI {
fetchClaudeUsage: (terminalId: string) => Promise<IPCResult>;
getBestAvailableProfile: (excludeProfileId?: string) => Promise<IPCResult<import('../../shared/types').ClaudeProfile | null>>;
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<IPCResult>;

// Usage Monitoring (Proactive Account Switching)
Expand Down Expand Up @@ -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<IPCResult> =>
ipcRenderer.invoke(IPC_CHANNELS.CLAUDE_RETRY_WITH_PROFILE, request),

Expand Down
4 changes: 4 additions & 0 deletions apps/frontend/src/renderer/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -1075,6 +1076,9 @@ export function App() {
{/* SDK Rate Limit Modal - shows when SDK/CLI operations hit limits (changelog, tasks, etc.) */}
<SDKRateLimitModal />

{/* Auth Failure Modal - shows when Claude CLI encounters 401/auth errors */}
<AuthFailureModal onOpenSettings={() => setIsSettingsDialogOpen(true)} />
Copy link

Choose a reason for hiding this comment

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

Settings button doesn't navigate to Claude profiles section

Medium Severity

The AuthFailureModal's "Go to Settings" button only calls setIsSettingsDialogOpen(true) without setting setSettingsInitialSection('integrations'). According to the PR test plan, this button should navigate directly to Claude profiles settings (which are under the 'integrations' section), but currently it opens settings at the default view, leaving users to find Claude profiles manually.

Fix in Cursor Fix in Web


{/* Onboarding Wizard - shows on first launch when onboardingCompleted is false */}
<OnboardingWizard
open={isOnboardingWizardOpen}
Expand Down
114 changes: 114 additions & 0 deletions apps/frontend/src/renderer/components/AuthFailureModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { useTranslation } from 'react-i18next';
import { AlertTriangle, Settings } from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from './ui/dialog';
import { Button } from './ui/button';
import { useAuthFailureStore } from '../stores/auth-failure-store';

interface AuthFailureModalProps {
onOpenSettings?: () => 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 || '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.');
Comment on lines +34 to +40
Copy link
Contributor

Choose a reason for hiding this comment

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

medium

The AuthFailureModal component uses several i18n keys (e.g., auth.failure.title, auth.failure.tokenExpired, auth.failure.description, auth.failure.goToSettings) that are not present in the provided common.json file. This will result in untranslated strings or fallback to the default English values, which might not be ideal for localization. Please ensure these keys are added to the common.json file for proper internationalization.

}
};
Comment on lines +31 to +42
Copy link
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

Consider memoizing getFailureMessage or inlining the switch.

The getFailureMessage function is recreated on every render. Since it depends on authFailureInfo.failureType and t, you could use useMemo or simply inline the logic. This is a minor optimization.

♻️ Optional: Memoize with useMemo
+import { useMemo } from 'react';
...
-  const getFailureMessage = () => {
-    switch (authFailureInfo.failureType) {
+  const failureMessage = useMemo(() => {
+    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();
+  }, [authFailureInfo?.failureType, t]);
🤖 Prompt for AI Agents
In `@apps/frontend/src/renderer/components/AuthFailureModal.tsx` around lines 31 -
42, getFailureMessage is recreated on every render even though it only depends
on authFailureInfo.failureType and the i18n function t; either inline the switch
where it's used or wrap the function's return value in useMemo (dependent on
[authFailureInfo.failureType, t]) to avoid unnecessary re-creation. Locate the
getFailureMessage helper in AuthFailureModal.tsx and replace it with a useMemo
hook that returns the switch result or move the switch logic directly into the
JSX where the message is consumed, keeping references to
authFailureInfo.failureType and t intact.


const failureMessage = getFailureMessage();

const handleGoToSettings = () => {
hideAuthFailureModal();
onOpenSettings?.();
};

const handleDismiss = () => {
clearAuthFailure();
};

return (
<Dialog open={isModalOpen} onOpenChange={(open) => !open && hideAuthFailureModal()}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<div className="flex items-center gap-3">
<div className="rounded-full bg-amber-100 dark:bg-amber-900/30 p-2">
<AlertTriangle className="h-5 w-5 text-amber-600 dark:text-amber-400" />
</div>
<div>
<DialogTitle className="text-lg">
{t('auth.failure.title', 'Authentication Required')}
</DialogTitle>
<DialogDescription className="text-sm text-muted-foreground">
{t('auth.failure.profileLabel', 'Profile')}: {profileName}
</DialogDescription>
</div>
</div>
</DialogHeader>

<div className="space-y-4 py-4">
<p className="text-sm text-foreground">
{failureMessage}
</p>
<p className="text-sm text-muted-foreground">
{t('auth.failure.description', 'Please re-authenticate your Claude profile to continue using Auto Claude.')}
</p>

{authFailureInfo.taskId && (
<div className="rounded-md bg-muted p-3 text-xs">
<p className="text-muted-foreground">
{t('auth.failure.taskAffected', 'Task affected')}: <span className="font-mono">{authFailureInfo.taskId}</span>
</p>
</div>
)}

{authFailureInfo.originalError && (
<details className="text-xs">
<summary className="cursor-pointer text-muted-foreground hover:text-foreground">
{t('auth.failure.technicalDetails', 'Technical details')}
</summary>
<pre className="mt-2 rounded-md bg-muted p-2 overflow-x-auto whitespace-pre-wrap break-all">
{authFailureInfo.originalError}
</pre>
</details>
)}
</div>

<DialogFooter className="flex-col sm:flex-row gap-2">
<Button variant="outline" onClick={handleDismiss} className="sm:mr-auto">
{t('common.dismiss', 'Dismiss')}
</Button>
<Button onClick={handleGoToSettings} className="gap-2">
<Settings className="h-4 w-4" />
{t('auth.failure.goToSettings', 'Go to Settings')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
18 changes: 17 additions & 1 deletion apps/frontend/src/renderer/hooks/useIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -352,6 +367,7 @@ export function useIpcListeners(): void {
cleanupRoadmapStopped();
cleanupRateLimit();
cleanupSDKRateLimit();
cleanupAuthFailure();
};
}, [updateTaskFromPlan, updateTaskStatus, updateExecutionProgress, appendLog, batchAppendLogs, setError]);
}
Expand Down
2 changes: 2 additions & 0 deletions apps/frontend/src/renderer/lib/mocks/claude-profile-mock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ export const claudeProfileMock = {

onSDKRateLimit: () => () => {},

onAuthFailure: () => () => {},

retryWithProfile: async () => ({ success: true }),

// Usage Monitoring (Proactive Account Switching)
Expand Down
43 changes: 43 additions & 0 deletions apps/frontend/src/renderer/stores/auth-failure-store.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { create } from 'zustand';
import type { AuthFailureInfo } from '../../shared/types';

interface AuthFailureState {
// Auth failure modal state
isModalOpen: boolean;
authFailureInfo: AuthFailureInfo | null;

// Track if there's a pending auth failure that needs attention
hasPendingAuthFailure: boolean;

// Actions
showAuthFailureModal: (info: AuthFailureInfo) => void;
hideAuthFailureModal: () => void;
clearAuthFailure: () => void;
}

export const useAuthFailureStore = create<AuthFailureState>((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,
});
},
}));
2 changes: 2 additions & 0 deletions apps/frontend/src/shared/constants/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Expand Down
3 changes: 3 additions & 0 deletions apps/frontend/src/shared/types/ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import type {
SessionDateRestoreResult,
RateLimitInfo,
SDKRateLimitInfo,
AuthFailureInfo,
RetryWithProfileRequest,
CreateTerminalWorktreeRequest,
TerminalWorktreeConfig,
Expand Down Expand Up @@ -297,6 +298,8 @@ export interface ElectronAPI {
getBestAvailableProfile: (excludeProfileId?: string) => Promise<IPCResult<ClaudeProfile | null>>;
/** 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<IPCResult>;

Expand Down
22 changes: 22 additions & 0 deletions apps/frontend/src/shared/types/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 */
detectedAt: Date;
}

/**
* Request to retry a rate-limited operation with a different profile
*/
Expand Down
Loading