diff --git a/docs/auth-revocation-handling.md b/docs/auth-revocation-handling.md new file mode 100644 index 00000000..f8e31f5b --- /dev/null +++ b/docs/auth-revocation-handling.md @@ -0,0 +1,146 @@ +# Auth Revocation Handling Design + +## Problem + +Claude (and other AI CLIs) have limited active OAuth sessions. When a user authenticates: +1. **Via relay** → Can revoke their local Claude instance's auth +2. **Locally** → Can revoke the relay workspace agent's auth + +Both scenarios need graceful handling. + +## Detection Patterns + +### Claude CLI Auth Revocation Indicators +``` +- "Your session has expired" +- "Please log in again" +- "Authentication required" +- "Unauthorized" +- "session expired" +- "invalid credentials" +- API responses with 401/403 +- CLI exit with auth-related error message +``` + +## Implementation Plan + +### 1. Add Auth Error Detection to Parser/Wrapper + +**File: `src/wrapper/auth-detection.ts` (new)** +```typescript +export const AUTH_REVOCATION_PATTERNS = [ + /session\s+(has\s+)?expired/i, + /please\s+log\s*in\s+again/i, + /authentication\s+required/i, + /unauthorized/i, + /invalid\s+credentials/i, + /not\s+authenticated/i, + /login\s+required/i, +]; + +export function detectAuthRevocation(output: string): boolean { + return AUTH_REVOCATION_PATTERNS.some(pattern => pattern.test(output)); +} +``` + +**File: `src/wrapper/tmux-wrapper.ts` (modify)** +- Import auth detection +- In output processing, check for auth revocation patterns +- When detected, emit 'auth_revoked' event and update status + +### 2. Add Agent Status: `auth_revoked` + +**File: `src/cloud/db/schema.ts`** +- Document that `status` can be: `active`, `idle`, `ended`, `auth_revoked` + +**File: `src/cloud/api/workspaces.ts`** +- Add endpoint to update agent auth status +- Add endpoint to trigger re-authentication + +### 3. Relay Protocol Extension + +**File: `src/protocol/types.ts`** +Add new envelope type for auth status: +```typescript +export interface AuthStatusPayload { + agentName: string; + status: 'revoked' | 'valid'; + provider: string; // 'claude', 'codex', etc. + message?: string; +} +``` + +### 4. Dashboard UI + +**File: `src/dashboard/react-components/AgentCard.tsx`** +- Show "Auth Required" badge when agent status is `auth_revoked` +- Show "Re-authenticate" button + +**File: `src/dashboard/react-components/AuthRevocationNotification.tsx` (new)** +- Toast/banner notification when auth is revoked +- Explains what happened and how to fix + +**File: `src/dashboard/react-components/AuthWarningModal.tsx` (new)** +- Warning before authenticating: "This may revoke other active sessions" +- Checkbox: "Don't show again" +- Continue / Cancel buttons + +### 5. Re-authentication Flow + +When user clicks "Re-authenticate": +1. Opens the existing CLI auth flow (`/api/cli/:provider/start`) +2. On success, agent status updated back to `active` +3. Agent resumes operation (may need to restart or reconnect) + +## Files to Create/Modify + +### New Files +- `src/wrapper/auth-detection.ts` - Auth error patterns and detection +- `src/dashboard/react-components/AuthRevocationNotification.tsx` +- `src/dashboard/react-components/AuthWarningModal.tsx` + +### Modified Files +- `src/wrapper/tmux-wrapper.ts` - Add auth detection in output processing +- `src/wrapper/base-wrapper.ts` - Add auth status events +- `src/protocol/types.ts` - Add AUTH_STATUS envelope type +- `src/daemon/router.ts` - Handle auth status messages +- `src/cloud/api/workspaces.ts` - Add auth status endpoints +- `src/dashboard/react-components/AgentCard.tsx` - Show auth status +- `src/dashboard/react-components/App.tsx` - Handle auth notifications +- `src/cloud/api/onboarding.ts` - Add pre-auth warning flag + +## API Endpoints + +### POST /api/workspaces/:id/agents/:agentName/reauth +Triggers re-authentication for an agent with revoked auth. + +### GET /api/workspaces/:id/agents/:agentName/auth-status +Returns current auth status for an agent. + +### POST /api/cli/:provider/start (existing, modify) +Add `skipWarning` parameter to bypass the session limit warning. + +## Event Flow + +``` +1. Agent running in workspace +2. User authenticates Claude locally +3. Cloud auth is revoked +4. Agent CLI outputs "session expired" or similar +5. Wrapper detects pattern → emits AUTH_REVOKED event +6. Daemon receives event → updates agent status +7. Cloud DB updated → status = 'auth_revoked' +8. Dashboard polls/receives status → shows notification +9. User clicks "Re-authenticate" +10. CLI auth flow starts +11. On success → agent status = 'active' +12. Agent resumes (or user restarts agent) +``` + +## Pre-Auth Warning + +Before starting any CLI auth: +1. Check if this is a provider with session limits (Claude, etc.) +2. Show modal: "Authenticating Claude here may sign out other sessions" +3. User confirms → proceed with auth +4. User can check "Don't warn me again" (stored in localStorage) diff --git a/src/dashboard/react-components/App.tsx b/src/dashboard/react-components/App.tsx index 268f3e5c..238eeae2 100644 --- a/src/dashboard/react-components/App.tsx +++ b/src/dashboard/react-components/App.tsx @@ -20,7 +20,6 @@ import { MentionAutocomplete, getMentionQuery, completeMentionInValue, type Huma import { FileAutocomplete, getFileQuery, completeFileInValue } from './FileAutocomplete'; import { WorkspaceSelector, type Workspace } from './WorkspaceSelector'; import { AddWorkspaceModal } from './AddWorkspaceModal'; -import { WorkspaceSettingsPanel } from './WorkspaceSettingsPanel'; import { LogViewerPanel } from './LogViewerPanel'; import { TrajectoryViewer } from './TrajectoryViewer'; import { DecisionQueue, type Decision } from './DecisionQueue'; @@ -201,9 +200,6 @@ export function App({ wsUrl, orchestratorUrl }: AppProps) { const [isAddingWorkspace, setIsAddingWorkspace] = useState(false); const [addWorkspaceError, setAddWorkspaceError] = useState(null); - // Workspace settings panel state - const [isWorkspaceSettingsPanelOpen, setIsWorkspaceSettingsPanelOpen] = useState(false); - // Command palette state const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); @@ -596,9 +592,10 @@ export function App({ wsUrl, orchestratorUrl }: AppProps) { setIsFullSettingsOpen(true); }, []); - // Handle workspace settings click - opens workspace settings panel + // Handle workspace settings click - opens full settings page with workspace tab const handleWorkspaceSettingsClick = useCallback(() => { - setIsWorkspaceSettingsPanelOpen(true); + setSettingsInitialTab('workspace'); + setIsFullSettingsOpen(true); }, []); // Handle history click @@ -1159,30 +1156,6 @@ export function App({ wsUrl, orchestratorUrl }: AppProps) { error={addWorkspaceError} /> - {/* Workspace Settings Panel */} - {effectiveActiveWorkspaceId && ( - setIsWorkspaceSettingsPanelOpen(false)} - workspaceId={effectiveActiveWorkspaceId} - workspaceName={effectiveWorkspaces.find(w => w.id === effectiveActiveWorkspaceId)?.name || 'Workspace'} - isOwner={true} - apiBaseUrl="/api" - onWorkspaceUpdated={() => { - // Refetch cloud workspaces if in cloud mode - if (isCloudMode) { - cloudApi.getWorkspaceSummary().then(result => { - if (result.success && result.data.workspaces) { - setCloudWorkspaces(result.data.workspaces); - } - }); - } - // Refetch workspace repos - refetchWorkspaceRepos(); - }} - /> - )} - {/* Conversation History */} pattern.test(text)); +} + +/** + * Provider-specific auth detection configuration. + * Different AI CLIs may have different error messages. + */ +export const PROVIDER_AUTH_PATTERNS: Record = { + claude: [ + /claude.*session.*expired/i, + /anthropic.*unauthorized/i, + /claude.*not\s+authenticated/i, + /please\s+run\s+claude\s+login/i, + ], + codex: [ + /codex.*session.*expired/i, + /openai.*unauthorized/i, + /codex.*not\s+authenticated/i, + ], + gemini: [ + /gemini.*session.*expired/i, + /google.*unauthorized/i, + /gemini.*not\s+authenticated/i, + ], +}; + +/** + * Detect auth revocation for a specific provider. + * Uses provider-specific patterns in addition to general patterns. + */ +export function detectProviderAuthRevocation( + output: string, + provider: string +): AuthRevocationResult { + // First check general patterns + const generalResult = detectAuthRevocation(output, true); + if (generalResult.detected && generalResult.confidence === 'high') { + return generalResult; + } + + // Check provider-specific patterns + const providerPatterns = PROVIDER_AUTH_PATTERNS[provider.toLowerCase()]; + if (providerPatterns) { + for (const pattern of providerPatterns) { + const match = output.match(pattern); + if (match) { + return { + detected: true, + pattern: pattern.source, + confidence: 'high', + message: match[0], + }; + } + } + } + + return generalResult; +} diff --git a/src/wrapper/tmux-wrapper.ts b/src/wrapper/tmux-wrapper.ts index 61134c24..83bdd587 100644 --- a/src/wrapper/tmux-wrapper.ts +++ b/src/wrapper/tmux-wrapper.ts @@ -38,6 +38,7 @@ import { type PDEROPhase, } from '../trajectory/integration.js'; import { escapeForShell } from '../bridge/utils.js'; +import { detectProviderAuthRevocation, type AuthRevocationResult } from './auth-detection.js'; import { type CliType, type InjectionCallbacks, @@ -136,6 +137,9 @@ export class TmuxWrapper extends BaseWrapper { private lastDetectedPhase?: PDEROPhase; // Track last auto-detected PDERO phase private seenToolCalls: Set = new Set(); // Dedup tool call trajectory events private seenErrors: Set = new Set(); // Dedup error trajectory events + private authRevoked = false; // Track if auth has been revoked + private lastAuthCheck = 0; // Timestamp of last auth check (throttle) + private readonly AUTH_CHECK_INTERVAL = 5000; // Check auth status every 5 seconds max constructor(config: TmuxWrapperConfig) { // Merge defaults with config @@ -750,6 +754,9 @@ export class TmuxWrapper extends BaseWrapper { // Use joinedContent to handle multi-line output from TUIs like Claude Code this.parseSpawnReleaseCommands(joinedContent); + // Check for auth revocation (limited sessions scenario) + this.checkAuthRevocation(cleanContent); + this.updateActivityState(); // Also check for injection opportunity @@ -793,6 +800,66 @@ export class TmuxWrapper extends BaseWrapper { } } + /** + * Check if the CLI output indicates auth has been revoked. + * This can happen when the user authenticates elsewhere (limited sessions). + */ + private checkAuthRevocation(output: string): void { + // Don't check if already revoked or if we checked recently + if (this.authRevoked) return; + const now = Date.now(); + if (now - this.lastAuthCheck < this.AUTH_CHECK_INTERVAL) return; + this.lastAuthCheck = now; + + // Get the CLI type/provider from config + const provider = this.config.program || this.cliType || 'claude'; + + // Check for auth revocation patterns in recent output + const result = detectProviderAuthRevocation(output, provider); + + if (result.detected && result.confidence !== 'low') { + this.authRevoked = true; + this.logStderr(`[AUTH] Auth revocation detected (${result.confidence} confidence): ${result.message}`); + + // Send auth status message to daemon + if (this.client.state === 'READY') { + const authPayload = JSON.stringify({ + type: 'auth_revoked', + agent: this.config.name, + provider, + message: result.message, + confidence: result.confidence, + timestamp: new Date().toISOString(), + }); + this.client.sendMessage('#system', authPayload, 'message'); + } + + // Emit event for listeners + this.emit('auth_revoked', { + agent: this.config.name, + provider, + message: result.message, + confidence: result.confidence, + }); + } + } + + /** + * Reset auth revocation state (called after successful re-authentication) + */ + public resetAuthState(): void { + this.authRevoked = false; + this.lastAuthCheck = 0; + this.logStderr('[AUTH] Auth state reset'); + } + + /** + * Check if auth has been revoked + */ + public isAuthRevoked(): boolean { + return this.authRevoked; + } + /** * Send relay command to daemon (overrides BaseWrapper for offline queue support) */