fix: replace broken OAuth login with token-based auth

This commit is contained in:
Zac Gaetano 2026-04-05 00:13:37 -04:00
parent 379d612388
commit 0046db1849

View file

@ -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"):