#!/usr/bin/env python3 """Local-only bridge between CASAN Control Panel and official provider CLIs. The bridge never reads credential files. It asks the installed Codex/Claude CLI for a sanitized status and can launch their official browser login flow. Only fixed commands are allowed and every request requires a generated bearer token supplied to the backend container by local-full.sh. """ import argparse import hmac import json import os import shutil import subprocess import threading from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from typing import Dict, Optional PROVIDERS = { "codex": { "label": "OpenAI Codex", "binary": "codex", "status": ["codex", "login", "status"], "login": ["codex", "login"], }, "claude": { "label": "Anthropic Claude Code", "binary": "claude", "status": ["claude", "auth", "status", "--json"], "login": ["claude", "auth", "login", "--claudeai"], }, } RUNNING: Dict[str, subprocess.Popen] = {} LOCK = threading.Lock() def command_status(provider: str) -> dict: config = PROVIDERS[provider] available = shutil.which(config["binary"]) is not None with LOCK: process = RUNNING.get(provider) authenticating = bool(process and process.poll() is None) if process and process.poll() is not None: RUNNING.pop(provider, None) if not available: return { "id": provider, "label": config["label"], "available": False, "loggedIn": False, "authenticating": False, "authMethod": "unavailable", } try: result = subprocess.run( config["status"], capture_output=True, text=True, timeout=8, env={**os.environ, "NO_COLOR": "1"}, ) except (OSError, subprocess.TimeoutExpired): return { "id": provider, "label": config["label"], "available": True, "loggedIn": False, "authenticating": authenticating, "authMethod": "unknown", } if provider == "claude": try: payload = json.loads(result.stdout or "{}") except ValueError: payload = {} logged_in = result.returncode == 0 and bool(payload.get("loggedIn")) method = str(payload.get("authMethod") or "none") else: text = (result.stdout + result.stderr).lower() logged_in = result.returncode == 0 and "logged in" in text method = "chatgpt" if "chatgpt" in text else ("api" if "api" in text and logged_in else "none") return { "id": provider, "label": config["label"], "available": True, "loggedIn": logged_in, "authenticating": authenticating and not logged_in, "authMethod": method, } def start_login(provider: str) -> dict: status = command_status(provider) if not status["available"]: return {"success": False, "reason": "cli_not_installed", "provider": status} if status["loggedIn"]: return {"success": True, "reason": "already_logged_in", "provider": status} with LOCK: current = RUNNING.get(provider) if current and current.poll() is None: return {"success": True, "reason": "login_in_progress", "provider": {**status, "authenticating": True}} process = subprocess.Popen( PROVIDERS[provider]["login"], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True, env={**os.environ, "NO_COLOR": "1"}, ) RUNNING[provider] = process return {"success": True, "reason": "browser_login_started", "provider": command_status(provider)} class BridgeHandler(BaseHTTPRequestHandler): server_version = "CASANAuthBridge/1.0" def log_message(self, _format: str, *_args) -> None: return def send_json(self, status: int, payload: dict) -> None: encoded = json.dumps(payload, ensure_ascii=False).encode("utf-8") self.send_response(status) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(encoded))) self.send_header("Cache-Control", "no-store") self.send_header("X-Content-Type-Options", "nosniff") self.end_headers() self.wfile.write(encoded) def authorized(self) -> bool: expected = self.server.bridge_token # type: ignore[attr-defined] supplied = self.headers.get("X-CASAN-Bridge-Token", "") return bool(expected) and hmac.compare_digest(expected, supplied) def do_GET(self) -> None: if self.path == "/healthz": self.send_json(200, {"status": "ok"}) return if not self.authorized(): self.send_json(401, {"success": False, "reason": "unauthorized"}) return if self.path == "/v1/auth/providers": self.send_json(200, {"success": True, "providers": [command_status(provider) for provider in PROVIDERS]}) return self.send_json(404, {"success": False, "reason": "not_found"}) def do_POST(self) -> None: if not self.authorized(): self.send_json(401, {"success": False, "reason": "unauthorized"}) return parts = [part for part in self.path.split("/") if part] if len(parts) == 4 and parts[:2] == ["v1", "auth"] and parts[3] == "login" and parts[2] in PROVIDERS: result = start_login(parts[2]) self.send_json(202 if result["success"] else 503, result) return self.send_json(404, {"success": False, "reason": "not_found"}) def read_token(path: str) -> str: try: with open(path, encoding="utf-8") as handle: token = handle.read().strip() except OSError: raise SystemExit("AUTH_BRIDGE_TOKEN_MISSING") if len(token) < 32: raise SystemExit("AUTH_BRIDGE_TOKEN_WEAK") return token def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--bind", default="127.0.0.1") parser.add_argument("--port", type=int, default=20130) parser.add_argument("--token-file", required=True) args = parser.parse_args() server = ThreadingHTTPServer((args.bind, args.port), BridgeHandler) server.bridge_token = read_token(args.token_file) # type: ignore[attr-defined] try: server.serve_forever() except KeyboardInterrupt: pass finally: server.server_close() return 0 if __name__ == "__main__": raise SystemExit(main())