Files
ai-agent/sidecar/routes_accounts.py
Gabriel Vidal cf01bc00f4 refactor(sidecar): split sidecar.py into focused modules
config / pids / validate / claude_accounts / launch / fork / routes_runs /
routes_accounts, mounted by a thin sidecar.py (same `sidecar:app`, same 24
routes, same startup hook). Mutable state keeps one owner module
(pids._procs, claude_accounts._account_status_cache). Includes the
_claude_args builder from 1de9426 and its test; the Dockerfile's explicit
COPY list gains the new modules.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 23:55:47 +02:00

112 lines
4.4 KiB
Python

"""
``/health`` and the account routes: ``/accounts`` (login health) and the
headless login / logout flow under ``/accounts/{account}/…``.
"""
import re
from fastapi import APIRouter, Header, HTTPException
from pydantic import BaseModel
import claude_accounts
import claude_cli
import pairing
from claude_accounts import (_account_env, _auth_status, _login_account,
_login_error)
from config import (ACCOUNTS, CLAUDE_BIN, DEFAULT_ACCOUNT, DEFAULT_CWD,
DEFAULT_MODEL, PERMISSION_MODE, TOKEN)
from validate import _auth
router = APIRouter()
@router.get("/health")
def health() -> dict:
"""Open (no bearer): the deploy probe and the hub's worker poller read it.
The worker fields say who this runner is — ``paired`` is whether a hub
holds its token (SIDECAR_TOKEN counts, so the host sidecar reads paired)."""
return {"ok": True, "cwd": DEFAULT_CWD, "claude": CLAUDE_BIN,
"defaultModel": DEFAULT_MODEL, "accounts": list(ACCOUNTS),
"defaultAccount": DEFAULT_ACCOUNT,
"permissionMode": PERMISSION_MODE,
**pairing.identity(), "paired": bool(TOKEN or pairing.token())}
@router.get("/accounts")
def accounts(authorization: str | None = Header(default=None)) -> dict:
"""Each account's login health (who it is, which org, which plan)."""
_auth(authorization)
return {"accounts": [{**_auth_status(a),
"pendingLogin": claude_cli.pending_login(a)}
for a in ACCOUNTS],
"default": DEFAULT_ACCOUNT}
# ---- login / logout ---------------------------------------------------------
# `claude auth login` driven headlessly — see claude_cli.py for the flow. Each
# account logs in under its own env (_account_env), so the credentials land in
# that account's config dir; the default account writes the host user's own
# ~/.claude login.
class LoginBody(BaseModel):
email: str | None = None # pre-fills the sign-in page (--email)
class LoginCodeBody(BaseModel):
code: str # the `code#state` the sign-in page shows
@router.post("/accounts/{account}/login")
def login_start(account: str, body: LoginBody | None = None,
authorization: str | None = Header(default=None)) -> dict:
"""Start a subscription login: returns the sign-in URL to open (on any
device); the code it ends with goes to ``/login/code``."""
_auth(authorization)
account = _login_account(account)
email = ((body.email if body else None) or "").strip() or None
if email and not re.fullmatch(r"[^@\s]+@[^@\s]+", email):
raise HTTPException(400, "invalid email")
try:
return claude_cli.start_login(account, [CLAUDE_BIN],
_account_env(account), email)
except claude_cli.LoginError as e:
raise _login_error(e, account)
@router.post("/accounts/{account}/login/code")
def login_code(account: str, body: LoginCodeBody,
authorization: str | None = Header(default=None)) -> dict:
"""Finish the pending login with the pasted code; answers the fresh
``auth status`` of the account."""
_auth(authorization)
account = _login_account(account)
try:
claude_cli.submit_code(account, body.code)
except claude_cli.LoginError as e:
raise _login_error(e, account)
claude_accounts._account_status_cache.pop(account, None)
return _auth_status(account)
@router.delete("/accounts/{account}/login")
def login_cancel(account: str,
authorization: str | None = Header(default=None)) -> dict:
_auth(authorization)
return {"cancelled": claude_cli.cancel_login(_login_account(account))}
@router.post("/accounts/{account}/logout")
def logout(account: str,
authorization: str | None = Header(default=None)) -> dict:
"""`claude auth logout` for the account. New launches on it fail with a
typed ``auth`` error until it logs back in; runs already in flight may
keep going on the access token they hold in memory."""
_auth(authorization)
account = _login_account(account)
claude_cli.cancel_login(account)
try:
msg = claude_cli.logout([CLAUDE_BIN], _account_env(account))
except claude_cli.LoginError as e:
raise _login_error(e, account)
claude_accounts._account_status_cache.pop(account, None)
return {**_auth_status(account), "message": msg}