From 0046db1849db5628a7fb54d80a580b463e557e1f Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 5 Apr 2026 00:13:37 -0400 Subject: [PATCH] fix: replace broken OAuth login with token-based auth --- backend/main.py | 318 +++++++++++++++++++++--------------------------- 1 file changed, 136 insertions(+), 182 deletions(-) diff --git a/backend/main.py b/backend/main.py index 28ebc72..1c01fa9 100644 --- a/backend/main.py +++ b/backend/main.py @@ -41,6 +41,7 @@ app.add_middleware( DB_PATH = Path("/app/data/tasks.db") LOGS_PATH = Path("/app/logs") TASKS_PATH = Path("/app/tasks") +TOKEN_FILE = Path("/app/data/.claude_token") # persist token across restarts # Ensure directories exist DB_PATH.parent.mkdir(parents=True, exist_ok=True) @@ -110,40 +111,57 @@ def init_db(): conn.close() +def _get_claude_env(): + """Get environment with auth tokens set for Claude CLI""" + env = os.environ.copy() + # Load saved token if it exists + if TOKEN_FILE.exists(): + try: + saved = json.loads(TOKEN_FILE.read_text()) + if saved.get("type") == "oauth_token" and saved.get("token"): + env["CLAUDE_CODE_OAUTH_TOKEN"] = saved["token"] + elif saved.get("type") == "api_key" and saved.get("token"): + env["ANTHROPIC_API_KEY"] = saved["token"] + except Exception as e: + logger.error(f"Failed to load saved token: {e}") + return env + + async def run_claude_task(task: Task, run_id: str): """Execute a Claude Code task""" + started = datetime.now().isoformat() try: - # Save task prompt to temp file - prompt_file = TASKS_PATH / f"{run_id}.md" - prompt_file.write_text(task.prompt) + env = _get_claude_env() - # Run Claude Code + # Run Claude Code in print mode (non-interactive) process = await asyncio.create_subprocess_exec( - "claude", - "task", - str(prompt_file), + "claude", "-p", "--allowedTools", "Bash,Read,Write,Edit", + "--permission-mode", "auto", + task.prompt, stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE + stderr=asyncio.subprocess.PIPE, + env=env ) stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=300) - output = stdout.decode() if stdout else "" - error = stderr.decode() if stderr else "" + output = stdout.decode(errors="replace") if stdout else "" + error = stderr.decode(errors="replace") if stderr else "" + + status = "completed" if process.returncode == 0 else "failed" # Save run result save_task_run(TaskRun( task_id=task.id, run_id=run_id, - status="completed" if process.returncode == 0 else "failed", + status=status, output=output, - error=error if process.returncode != 0 else None, - started_at=datetime.now().isoformat(), + error=error if status == "failed" else None, + started_at=started, completed_at=datetime.now().isoformat() )) - # Update task - update_task_status(task.id, "completed") + update_task_status(task.id, status) except asyncio.TimeoutError: save_task_run(TaskRun( @@ -151,7 +169,7 @@ async def run_claude_task(task: Task, run_id: str): run_id=run_id, status="failed", error="Task timeout (>5 minutes)", - started_at=datetime.now().isoformat(), + started_at=started, completed_at=datetime.now().isoformat() )) update_task_status(task.id, "failed") @@ -161,7 +179,7 @@ async def run_claude_task(task: Task, run_id: str): run_id=run_id, status="failed", error=str(e), - started_at=datetime.now().isoformat(), + started_at=started, completed_at=datetime.now().isoformat() )) update_task_status(task.id, "failed") @@ -294,6 +312,19 @@ async def startup(): init_db() scheduler.start() + # Load saved token into environment on startup + if TOKEN_FILE.exists(): + try: + saved = json.loads(TOKEN_FILE.read_text()) + if saved.get("type") == "oauth_token" and saved.get("token"): + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = saved["token"] + logger.info("Loaded saved OAuth token") + elif saved.get("type") == "api_key" and saved.get("token"): + os.environ["ANTHROPIC_API_KEY"] = saved["token"] + logger.info("Loaded saved API key") + except Exception as e: + logger.error(f"Failed to load saved token: {e}") + # Schedule existing tasks for task in get_all_tasks(): if task.enabled: @@ -432,8 +463,6 @@ async def system_info() -> Dict: @app.get("/api/system/usage") async def usage_stats() -> Dict: """Get Claude API usage stats from session files if available""" - import glob, json as _json - usage = { "models_used": [], "session_count": 0, @@ -443,19 +472,16 @@ async def usage_stats() -> Dict: } try: - # Claude Code stores session info under ~/.claude claude_dir = Path("/root/.claude") sessions = list(claude_dir.glob("**/session*.json")) + list(claude_dir.glob("**/*.jsonl")) usage["session_count"] = len(sessions) - # Try to parse any usage metadata stats_file = claude_dir / "usage_stats.json" if stats_file.exists(): with open(stats_file) as f: - saved = _json.load(f) + saved = json.load(f) usage.update(saved) else: - # Estimate reset time: Anthropic resets usage monthly now = datetime.now() if now.day <= 1: reset = now.replace(day=1, hour=0, minute=0, second=0) @@ -467,7 +493,6 @@ async def usage_stats() -> Dict: except Exception as e: usage["error"] = str(e) - # Add run stats for context conn = sqlite3.connect(DB_PATH) cursor = conn.cursor() cursor.execute("SELECT COUNT(*), MIN(started_at), MAX(started_at) FROM task_runs") @@ -480,203 +505,136 @@ async def usage_stats() -> Dict: return usage -# Auth state stored in memory (persists while container runs) -_auth_process = None -_auth_url = None -_auth_status = "unknown" # unknown | logged_in | logged_out | pending - - -async def _check_claude_auth() -> str: - """Check if claude is authenticated by running claude auth status""" - global _auth_status - try: - proc = await asyncio.create_subprocess_exec( - "claude", "auth", "status", - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10) - output = stdout.decode(errors="replace").strip() - logger.info(f"claude auth status output: {output}") - try: - import json as _json - data = _json.loads(output) - if data.get("loggedIn"): - _auth_status = "logged_in" - return "logged_in" - except Exception: - pass - _auth_status = "logged_out" - return "logged_out" - except Exception as e: - logger.error(f"Auth check failed: {e}") - _auth_status = "unknown" - return "unknown" +# ============ Auth Endpoints ============ @app.get("/api/auth/status") async def auth_status(): - """Check Claude auth status using claude auth status JSON""" - global _auth_status, _auth_url + """Check Claude auth status""" account = None auth_method = None + status = "logged_out" + has_saved_token = TOKEN_FILE.exists() + token_type = None + + if has_saved_token: + try: + saved = json.loads(TOKEN_FILE.read_text()) + token_type = saved.get("type") + except Exception: + pass + try: + env = _get_claude_env() proc = await asyncio.create_subprocess_exec( - "claude", "auth", "status", - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE + "claude", "auth", "status", "--json", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env=env ) stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10) output = stdout.decode(errors="replace").strip() - import json as _json - data = _json.loads(output) + logger.info(f"claude auth status: {output}") + data = json.loads(output) if data.get("loggedIn"): - _auth_status = "logged_in" + status = "logged_in" account = data.get("email") or data.get("account", {}).get("emailAddress") auth_method = data.get("authMethod") - else: - _auth_status = "logged_out" except Exception as e: logger.error(f"Auth status check failed: {e}") - # Fall back to existing status - pass + return { - "status": _auth_status, + "status": status, "account": account, "auth_method": auth_method, - "auth_url": _auth_url if _auth_status == "pending" else None + "has_saved_token": has_saved_token, + "token_type": token_type } -@app.post("/api/auth/login") -async def auth_login(): - """Initiate Claude OAuth login - returns the URL to open in browser""" - global _auth_process, _auth_url, _auth_status +class TokenSubmit(BaseModel): + token: str + token_type: str = "oauth_token" # "oauth_token" or "api_key" - # Kill any existing auth process - if _auth_process and _auth_process.returncode is None: - try: - _auth_process.kill() - except Exception: - pass - _auth_status = "pending" - _auth_url = None +@app.post("/api/auth/token") +async def auth_set_token(payload: TokenSubmit): + """Save an auth token (OAuth setup token or API key)""" + token = payload.token.strip() + token_type = payload.token_type + if not token: + return {"status": "error", "message": "Token cannot be empty"} + + # Auto-detect token type + if token.startswith("sk-ant-oat"): + token_type = "oauth_token" + elif token.startswith("sk-ant-api"): + token_type = "api_key" + + # Save to file for persistence + TOKEN_FILE.write_text(json.dumps({ + "type": token_type, + "token": token, + "saved_at": datetime.now().isoformat() + })) + + # Set in current process environment + if token_type == "oauth_token": + os.environ["CLAUDE_CODE_OAUTH_TOKEN"] = token + os.environ.pop("ANTHROPIC_API_KEY", None) + elif token_type == "api_key": + os.environ["ANTHROPIC_API_KEY"] = token + os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None) + + # Verify auth works try: - # Run claude auth login --claudeai with stdin PIPE so we can send the code later + env = _get_claude_env() proc = await asyncio.create_subprocess_exec( - "claude", "auth", "login", "--claudeai", - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.STDOUT, - stdin=asyncio.subprocess.PIPE + "claude", "auth", "status", "--json", + stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, + env=env ) - _auth_process = proc - - # Read output looking for the OAuth URL - url = None - try: - async def _read_url(): - collected = "" - while True: - line = await proc.stdout.readline() - if not line: - break - text = line.decode(errors="replace") - collected += text - logger.info(f"claude auth: {text.strip()}") - for part in text.split(): - cleaned = part.strip().rstrip("\\n") - if cleaned.startswith("https://") and ("oauth" in cleaned or "claude.com" in cleaned): - return cleaned - logger.info(f"Full auth output: {collected}") - return None - url = await asyncio.wait_for(_read_url(), timeout=15) - except asyncio.TimeoutError: - logger.warning("Timed out waiting for auth URL") - - if url: - _auth_url = url + stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=10) + output = stdout.decode(errors="replace").strip() + data = json.loads(output) + if data.get("loggedIn"): return { - "status": "pending", - "auth_url": url, - "message": "Open this URL, authorize, then paste the code you receive back here." + "status": "logged_in", + "message": "Token saved and verified!", + "account": data.get("email"), + "auth_method": data.get("authMethod") } else: return { - "status": "error", - "message": "Could not extract login URL. Try: docker exec -it claude-persistent-agent claude auth login --claudeai" + "status": "token_saved", + "message": "Token saved but auth status shows not logged in. The token may still work for API calls.", + "raw": output } - except Exception as e: - logger.error(f"Auth login failed: {e}") - return {"status": "error", "message": str(e)} - - -class AuthCode(BaseModel): - code: str - - -@app.post("/api/auth/code") -async def auth_submit_code(payload: AuthCode): - """Submit the OAuth authorization code from the browser callback""" - global _auth_process, _auth_status - - if not _auth_process or _auth_process.returncode is not None: - return {"status": "error", "message": "No active login session. Click 'Login' first."} - - try: - # Send the code to the waiting claude auth login process via stdin - code = payload.code.strip() + "\n" - _auth_process.stdin.write(code.encode()) - await _auth_process.stdin.drain() - _auth_process.stdin.close() - - # Wait for the process to finish - try: - stdout, _ = await asyncio.wait_for(_auth_process.communicate(), timeout=30) - output = stdout.decode(errors="replace") if stdout else "" - logger.info(f"Auth code result: {output}") - except asyncio.TimeoutError: - logger.warning("Auth process timed out after submitting code") - - # Check if login succeeded - check_proc = await asyncio.create_subprocess_exec( - "claude", "auth", "status", - stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE - ) - check_stdout, _ = await asyncio.wait_for(check_proc.communicate(), timeout=10) - check_output = check_stdout.decode(errors="replace").strip() - - try: - import json as _json - data = _json.loads(check_output) - if data.get("loggedIn"): - _auth_status = "logged_in" - return {"status": "logged_in", "message": "Successfully logged in!", "account": data.get("email")} - except Exception: - pass - - _auth_status = "logged_out" - return {"status": "error", "message": f"Login may have failed. Auth status: {check_output}"} - - except Exception as e: - logger.error(f"Auth code submission failed: {e}") - return {"status": "error", "message": str(e)} + return { + "status": "token_saved", + "message": f"Token saved. Could not verify: {e}" + } @app.post("/api/auth/logout") async def auth_logout(): - """Log out of Claude""" - global _auth_status + """Clear saved auth token""" + if TOKEN_FILE.exists(): + TOKEN_FILE.unlink() + os.environ.pop("CLAUDE_CODE_OAUTH_TOKEN", None) + os.environ.pop("ANTHROPIC_API_KEY", None) + try: proc = await asyncio.create_subprocess_exec( "claude", "auth", "logout", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) await asyncio.wait_for(proc.communicate(), timeout=10) - _auth_status = "logged_out" - return {"status": "logged_out"} - except Exception as e: - return {"status": "error", "message": str(e)} + except Exception: + pass + + return {"status": "logged_out"} # ============ MCP Server Management ============ @@ -693,21 +651,17 @@ async def list_mcp_servers(): stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=10) output = stdout.decode(errors="replace").strip() - # Parse the output — claude mcp list shows servers in a table or JSON servers = [] if "No MCP servers configured" in output: return {"servers": [], "raw": output} - # Try JSON parse first try: - import json as _json - data = _json.loads(output) + data = json.loads(output) if isinstance(data, list): servers = data elif isinstance(data, dict): servers = list(data.values()) if data else [] except Exception: - # Parse text output line by line for line in output.split("\n"): line = line.strip() if line and not line.startswith("-") and not line.startswith("Name"):