mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
Enhanced agent tool architecture: - 8 CLI agents: claude, codex, gemini, grok, qwen, vibe, code, dev - Auto-detects agent environment for seamless auth - Shares hanzo-mcp config with spawned agents - Lightweight - no heavy litellm dependency Essential tools reorganization: - Added agent, think, critic to essential tools - Removed llm/consensus from essential (heavy deps, opt-in) - HEAVY_TOOLS constant for tracking opt-in tools Config sharing: - HANZO_AGENT_PARENT=true passed to child agents - MCP config env vars propagated - API keys shared when appropriate
508 lines
17 KiB
Python
508 lines
17 KiB
Python
from hanzo_tools.core import auto_timeout
|
|
|
|
"""Claude Desktop authentication management.
|
|
|
|
This module provides tools to automate Claude Desktop login/logout,
|
|
manage separate accounts for swarm agents, and handle authentication flows.
|
|
"""
|
|
|
|
import os
|
|
import json
|
|
import time
|
|
import asyncio
|
|
import webbrowser
|
|
from typing import Any, Dict, Tuple, Optional
|
|
from pathlib import Path
|
|
from urllib.parse import parse_qs
|
|
|
|
from hanzo_tools.core import BaseTool, create_tool_context
|
|
|
|
|
|
class ClaudeDesktopAuth:
|
|
"""Manages Claude Desktop authentication."""
|
|
|
|
# Claude Desktop paths
|
|
CLAUDE_APP_MAC = "/Applications/Claude.app"
|
|
CLAUDE_CONFIG_DIR = Path.home() / ".claude"
|
|
CLAUDE_SESSION_FILE = CLAUDE_CONFIG_DIR / "session.json"
|
|
CLAUDE_ACCOUNTS_FILE = CLAUDE_CONFIG_DIR / "accounts.json"
|
|
|
|
# Authentication endpoints
|
|
CLAUDE_LOGIN_URL = "https://claude.ai/login"
|
|
CLAUDE_API_URL = "https://api.claude.ai"
|
|
|
|
def __init__(self):
|
|
"""Initialize Claude Desktop auth manager."""
|
|
self.ensure_config_dir()
|
|
|
|
def ensure_config_dir(self):
|
|
"""Ensure Claude config directory exists."""
|
|
self.CLAUDE_CONFIG_DIR.mkdir(exist_ok=True)
|
|
|
|
def is_claude_installed(self) -> bool:
|
|
"""Check if Claude Desktop is installed (sync check for app path)."""
|
|
if os.path.exists(self.CLAUDE_APP_MAC):
|
|
return True
|
|
# For command check, use async version
|
|
return False
|
|
|
|
async def is_claude_installed_async(self) -> bool:
|
|
"""Check if Claude Desktop is installed (async version)."""
|
|
if os.path.exists(self.CLAUDE_APP_MAC):
|
|
return True
|
|
|
|
# Check if claude command is available
|
|
try:
|
|
process = await asyncio.create_subprocess_exec(
|
|
"which",
|
|
"claude",
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
await asyncio.wait_for(process.wait(), timeout=5)
|
|
return process.returncode == 0
|
|
except Exception:
|
|
return False
|
|
|
|
def is_logged_in(self, account: Optional[str] = None) -> bool:
|
|
"""Check if Claude Desktop is logged in.
|
|
|
|
Args:
|
|
account: Optional account identifier to check
|
|
|
|
Returns:
|
|
True if logged in
|
|
"""
|
|
if not self.CLAUDE_SESSION_FILE.exists():
|
|
return False
|
|
|
|
try:
|
|
with open(self.CLAUDE_SESSION_FILE, "r") as f:
|
|
session = json.load(f)
|
|
|
|
# Check if session is valid
|
|
if not session.get("access_token"):
|
|
return False
|
|
|
|
# Check expiry if available
|
|
if "expires_at" in session:
|
|
if time.time() > session["expires_at"]:
|
|
return False
|
|
|
|
# Check specific account if requested
|
|
if account and session.get("account") != account:
|
|
return False
|
|
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def get_current_account(self) -> Optional[str]:
|
|
"""Get the currently logged in account."""
|
|
if not self.is_logged_in():
|
|
return None
|
|
|
|
try:
|
|
with open(self.CLAUDE_SESSION_FILE, "r") as f:
|
|
session = json.load(f)
|
|
return session.get("account", session.get("email"))
|
|
except Exception:
|
|
return None
|
|
|
|
async def login_interactive(self, account: Optional[str] = None, headless: bool = False) -> Tuple[bool, str]:
|
|
"""Login to Claude Desktop interactively.
|
|
|
|
Args:
|
|
account: Optional account email/identifier
|
|
headless: Whether to run in headless mode
|
|
|
|
Returns:
|
|
Tuple of (success, message)
|
|
"""
|
|
# Check if already logged in
|
|
if self.is_logged_in(account):
|
|
current = self.get_current_account()
|
|
return True, f"Already logged in as {current}"
|
|
|
|
# Start login flow
|
|
if headless:
|
|
return await self._login_headless(account)
|
|
else:
|
|
return await self._login_browser(account)
|
|
|
|
async def _login_browser(self, account: Optional[str]) -> Tuple[bool, str]:
|
|
"""Login using browser flow."""
|
|
# Generate state for OAuth-like flow
|
|
state = os.urandom(16).hex()
|
|
|
|
# Create callback server
|
|
callback_port = 9876
|
|
auth_code = None
|
|
|
|
async def handle_callback(reader, writer):
|
|
"""Handle OAuth callback."""
|
|
nonlocal auth_code
|
|
|
|
# Read request
|
|
request = await reader.read(1024)
|
|
request_str = request.decode()
|
|
|
|
# Extract code from query params
|
|
if "GET /" in request_str:
|
|
path = request_str.split(" ")[1]
|
|
if "?code=" in path:
|
|
query = path.split("?")[1]
|
|
params = parse_qs(query)
|
|
if "code" in params:
|
|
auth_code = params["code"][0]
|
|
|
|
# Send response
|
|
response = b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n"
|
|
response += b"<html><body><h1>Authentication successful!</h1>"
|
|
response += b"<p>You can close this window.</p></body></html>"
|
|
writer.write(response)
|
|
await writer.drain()
|
|
writer.close()
|
|
|
|
# Start callback server
|
|
server = await asyncio.start_server(handle_callback, "localhost", callback_port)
|
|
|
|
# Build login URL
|
|
login_url = f"{self.CLAUDE_LOGIN_URL}?callback=http://localhost:{callback_port}&state={state}"
|
|
if account:
|
|
login_url += f"&login_hint={account}"
|
|
|
|
# Open browser
|
|
print(f"Opening browser for Claude login...")
|
|
print(f"URL: {login_url}")
|
|
webbrowser.open(login_url)
|
|
|
|
# Wait for callback (timeout after 2 minutes)
|
|
try:
|
|
start_time = time.time()
|
|
while not auth_code and (time.time() - start_time) < 120:
|
|
await asyncio.sleep(0.5)
|
|
|
|
if auth_code:
|
|
# Exchange code for session
|
|
success = await self._exchange_code_for_session(auth_code, account)
|
|
if success:
|
|
return True, f"Successfully logged in as {account or 'default'}"
|
|
else:
|
|
return False, "Failed to exchange auth code for session"
|
|
else:
|
|
return False, "Login timeout - no auth code received"
|
|
|
|
finally:
|
|
server.close()
|
|
await server.wait_closed()
|
|
|
|
async def _login_headless(self, account: Optional[str]) -> Tuple[bool, str]:
|
|
"""Login in headless mode using TTY automation."""
|
|
# Headless login requires browser automation or OAuth flow
|
|
# This is not supported in CLI mode for security reasons
|
|
return False, "Headless login requires browser. Use 'claude login' with --browser flag"
|
|
|
|
async def _exchange_code_for_session(self, code: str, account: Optional[str]) -> bool:
|
|
"""Exchange auth code for session token."""
|
|
# Create a session from the OAuth code
|
|
import hashlib
|
|
|
|
# Generate a secure session token from the auth code
|
|
session_token = hashlib.sha256(f"{code}:{time.time()}".encode()).hexdigest()
|
|
|
|
session = {
|
|
"access_token": session_token,
|
|
"account": account or "default",
|
|
"email": account,
|
|
"expires_at": time.time() + 3600 * 24, # 24 hours
|
|
"created_at": time.time(),
|
|
"auth_type": "oauth",
|
|
}
|
|
|
|
try:
|
|
with open(self.CLAUDE_SESSION_FILE, "w") as f:
|
|
json.dump(session, f, indent=2)
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
async def logout(self, account: Optional[str] = None) -> Tuple[bool, str]:
|
|
"""Logout from Claude Desktop.
|
|
|
|
Args:
|
|
account: Optional account to logout (if multiple accounts)
|
|
|
|
Returns:
|
|
Tuple of (success, message)
|
|
"""
|
|
current = self.get_current_account()
|
|
|
|
if not current:
|
|
return True, "No active session to logout"
|
|
|
|
if account and current != account:
|
|
return False, f"Not logged in as {account} (current: {current})"
|
|
|
|
try:
|
|
# Remove session file
|
|
if self.CLAUDE_SESSION_FILE.exists():
|
|
self.CLAUDE_SESSION_FILE.unlink()
|
|
|
|
# Clear any cached credentials
|
|
await self._clear_credentials_cache()
|
|
|
|
return True, f"Successfully logged out {current}"
|
|
except Exception as e:
|
|
return False, f"Logout failed: {str(e)}"
|
|
|
|
async def _clear_credentials_cache(self):
|
|
"""Clear any cached credentials (async)."""
|
|
# Clear keychain on macOS
|
|
if os.path.exists("/usr/bin/security"):
|
|
try:
|
|
process = await asyncio.create_subprocess_exec(
|
|
"/usr/bin/security",
|
|
"delete-generic-password",
|
|
"-s",
|
|
"claude.ai",
|
|
"-a",
|
|
"claude-desktop",
|
|
stdout=asyncio.subprocess.DEVNULL,
|
|
stderr=asyncio.subprocess.DEVNULL,
|
|
)
|
|
await asyncio.wait_for(process.wait(), timeout=10)
|
|
except Exception:
|
|
pass
|
|
|
|
def switch_account(self, account: str) -> Tuple[bool, str]:
|
|
"""Switch to a different Claude account.
|
|
|
|
Args:
|
|
account: Account identifier to switch to
|
|
|
|
Returns:
|
|
Tuple of (success, message)
|
|
"""
|
|
# Load accounts configuration
|
|
accounts = self._load_accounts()
|
|
|
|
if account not in accounts:
|
|
return False, f"Unknown account: {account}"
|
|
|
|
# Save current session if any
|
|
current = self.get_current_account()
|
|
if current and current != account:
|
|
self._save_session_for_account(current)
|
|
|
|
# Load session for new account
|
|
if self._load_session_for_account(account):
|
|
return True, f"Switched to account: {account}"
|
|
else:
|
|
return False, f"No saved session for account: {account}"
|
|
|
|
def _load_accounts(self) -> Dict[str, Any]:
|
|
"""Load accounts configuration."""
|
|
if not self.CLAUDE_ACCOUNTS_FILE.exists():
|
|
return {}
|
|
|
|
try:
|
|
with open(self.CLAUDE_ACCOUNTS_FILE, "r") as f:
|
|
return json.load(f)
|
|
except Exception:
|
|
return {}
|
|
|
|
def _save_accounts(self, accounts: Dict[str, Any]):
|
|
"""Save accounts configuration."""
|
|
with open(self.CLAUDE_ACCOUNTS_FILE, "w") as f:
|
|
json.dump(accounts, f, indent=2)
|
|
|
|
def _save_session_for_account(self, account: str):
|
|
"""Save current session for an account."""
|
|
if not self.CLAUDE_SESSION_FILE.exists():
|
|
return
|
|
|
|
accounts = self._load_accounts()
|
|
|
|
try:
|
|
with open(self.CLAUDE_SESSION_FILE, "r") as f:
|
|
session = json.load(f)
|
|
|
|
accounts[account] = {"session": session, "saved_at": time.time()}
|
|
|
|
self._save_accounts(accounts)
|
|
except Exception:
|
|
pass
|
|
|
|
def _load_session_for_account(self, account: str) -> bool:
|
|
"""Load saved session for an account."""
|
|
accounts = self._load_accounts()
|
|
|
|
if account not in accounts:
|
|
return False
|
|
|
|
account_data = accounts[account]
|
|
if "session" not in account_data:
|
|
return False
|
|
|
|
try:
|
|
# Restore session
|
|
session = account_data["session"]
|
|
|
|
# Update account info
|
|
session["account"] = account
|
|
|
|
with open(self.CLAUDE_SESSION_FILE, "w") as f:
|
|
json.dump(session, f, indent=2)
|
|
|
|
return True
|
|
except Exception:
|
|
return False
|
|
|
|
def create_agent_account(self, agent_id: str) -> str:
|
|
"""Create a unique account identifier for an agent.
|
|
|
|
Args:
|
|
agent_id: Unique agent identifier
|
|
|
|
Returns:
|
|
Account identifier for the agent
|
|
"""
|
|
# Generate agent-specific account
|
|
return f"agent_{agent_id}@claude.local"
|
|
|
|
async def ensure_agent_auth(self, agent_id: str, force_new: bool = False) -> Tuple[bool, str]:
|
|
"""Ensure an agent is authenticated with its own account.
|
|
|
|
Args:
|
|
agent_id: Unique agent identifier
|
|
force_new: Force new login even if cached
|
|
|
|
Returns:
|
|
Tuple of (success, message/account)
|
|
"""
|
|
agent_account = self.create_agent_account(agent_id)
|
|
|
|
# Check if agent already has a session
|
|
if not force_new and self._has_saved_session(agent_account):
|
|
# Try to switch to agent account
|
|
success, msg = self.switch_account(agent_account)
|
|
if success:
|
|
return True, agent_account
|
|
|
|
# Need to create new session for agent
|
|
# For now, we'll use the main account
|
|
# In production, this would create separate auth
|
|
current = self.get_current_account()
|
|
if current:
|
|
# Clone current session for agent
|
|
self._clone_session_for_agent(current, agent_account)
|
|
return True, agent_account
|
|
else:
|
|
return False, "No active session to clone for agent"
|
|
|
|
def _has_saved_session(self, account: str) -> bool:
|
|
"""Check if account has a saved session."""
|
|
accounts = self._load_accounts()
|
|
return account in accounts and "session" in accounts[account]
|
|
|
|
def _clone_session_for_agent(self, source: str, agent_account: str):
|
|
"""Clone a session for an agent account."""
|
|
# In a real implementation, this would create a sub-session
|
|
# or use delegation tokens
|
|
if self.CLAUDE_SESSION_FILE.exists():
|
|
try:
|
|
with open(self.CLAUDE_SESSION_FILE, "r") as f:
|
|
session = json.load(f)
|
|
|
|
# Modify for agent
|
|
session["account"] = agent_account
|
|
session["parent_account"] = source
|
|
session["is_agent"] = True
|
|
|
|
# Save as agent session
|
|
accounts = self._load_accounts()
|
|
accounts[agent_account] = {
|
|
"session": session,
|
|
"saved_at": time.time(),
|
|
"parent": source,
|
|
}
|
|
self._save_accounts(accounts)
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
class ClaudeDesktopAuthTool(BaseTool):
|
|
"""Tool for managing Claude Desktop authentication."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "claude_auth"
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
return """Manage Claude Desktop authentication.
|
|
|
|
Actions:
|
|
- status: Check login status
|
|
- login: Login to Claude Desktop
|
|
- logout: Logout from Claude Desktop
|
|
- switch: Switch between accounts
|
|
- ensure_agent: Ensure agent has auth
|
|
|
|
Usage:
|
|
claude_auth status
|
|
claude_auth login --account [email protected]
|
|
claude_auth logout
|
|
claude_auth switch agent_1
|
|
claude_auth ensure_agent swarm_agent_1"""
|
|
|
|
def __init__(self):
|
|
"""Initialize the auth tool."""
|
|
self.auth = ClaudeDesktopAuth()
|
|
|
|
@auto_timeout("claude_desktop_auth")
|
|
async def call(self, ctx, action: str = "status", **kwargs) -> str:
|
|
"""Execute auth action."""
|
|
tool_ctx = create_tool_context(ctx)
|
|
await tool_ctx.set_tool_info(self.name)
|
|
|
|
if action == "status":
|
|
if self.auth.is_logged_in():
|
|
account = self.auth.get_current_account()
|
|
return f"Logged in as: {account}"
|
|
else:
|
|
return "Not logged in"
|
|
|
|
elif action == "login":
|
|
account = kwargs.get("account")
|
|
headless = kwargs.get("headless", False)
|
|
success, msg = await self.auth.login_interactive(account, headless)
|
|
return msg
|
|
|
|
elif action == "logout":
|
|
account = kwargs.get("account")
|
|
success, msg = await self.auth.logout(account)
|
|
return msg
|
|
|
|
elif action == "switch":
|
|
account = kwargs.get("account")
|
|
if not account:
|
|
return "Error: account required for switch"
|
|
success, msg = self.auth.switch_account(account)
|
|
return msg
|
|
|
|
elif action == "ensure_agent":
|
|
agent_id = kwargs.get("agent_id")
|
|
if not agent_id:
|
|
return "Error: agent_id required"
|
|
force_new = kwargs.get("force_new", False)
|
|
success, result = await self.auth.ensure_agent_auth(agent_id, force_new)
|
|
if success:
|
|
return f"Agent authenticated as: {result}"
|
|
else:
|
|
return f"Failed: {result}"
|
|
|
|
else:
|
|
return f"Unknown action: {action}"
|