332 lines
13 KiB
Python
332 lines
13 KiB
Python
#!/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 hashlib
|
|
import hmac
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from datetime import datetime, timezone
|
|
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()
|
|
MODEL_GATES = {provider: threading.Semaphore(1) for provider in PROVIDERS}
|
|
MODEL_WINDOWS = {provider: deque() for provider in PROVIDERS}
|
|
RATE_LOCK = threading.Lock()
|
|
AUDIT_LOCK = threading.Lock()
|
|
|
|
|
|
def model_call_allowed(provider: str) -> bool:
|
|
timestamp = time.monotonic()
|
|
with RATE_LOCK:
|
|
window = MODEL_WINDOWS[provider]
|
|
while window and timestamp - window[0] > 600:
|
|
window.popleft()
|
|
if len(window) >= 10:
|
|
return False
|
|
window.append(timestamp)
|
|
return True
|
|
|
|
|
|
def audit_model_call(path: str, provider: str, status: str, prompt: str, latency_ms: int) -> None:
|
|
if not path:
|
|
return
|
|
record = {
|
|
"timestamp": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
"provider": provider,
|
|
"status": status,
|
|
"prompt_hash": hashlib.sha256(prompt.encode("utf-8")).hexdigest(),
|
|
"prompt_characters": len(prompt),
|
|
"latency_ms": latency_ms,
|
|
}
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with AUDIT_LOCK, open(path, "a", encoding="utf-8") as handle:
|
|
handle.write(json.dumps(record, ensure_ascii=False) + "\n")
|
|
handle.flush()
|
|
os.chmod(path, 0o600)
|
|
|
|
|
|
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)}
|
|
|
|
|
|
def run_account_command(provider: str, prompt: str, directory: str):
|
|
if provider == "codex":
|
|
output_path = os.path.join(directory, "last-message.txt")
|
|
command = [
|
|
"codex", "exec", "--ephemeral", "--ignore-user-config", "--ignore-rules",
|
|
"--skip-git-repo-check", "--sandbox", "read-only", "--color", "never",
|
|
"--cd", directory, "--output-last-message", output_path, "-",
|
|
]
|
|
result = subprocess.run(
|
|
command, input=prompt, capture_output=True, text=True, timeout=300,
|
|
env={**os.environ, "NO_COLOR": "1"},
|
|
)
|
|
text = ""
|
|
if result.returncode == 0 and os.path.isfile(output_path):
|
|
with open(output_path, encoding="utf-8") as handle:
|
|
text = handle.read().strip()
|
|
return result, text, {}, "codex-account-default"
|
|
|
|
command = [
|
|
"claude", "--print", "--output-format", "json", "--permission-mode", "plan",
|
|
"--tools", "", "--safe-mode", "--no-session-persistence",
|
|
]
|
|
result = subprocess.run(
|
|
command, input=prompt, capture_output=True, text=True, timeout=300, cwd=directory,
|
|
env={**os.environ, "NO_COLOR": "1"},
|
|
)
|
|
try:
|
|
payload = json.loads(result.stdout or "{}")
|
|
except ValueError:
|
|
payload = {}
|
|
text = str(payload.get("result") or "").strip()
|
|
raw_usage = payload.get("usage") if isinstance(payload.get("usage"), dict) else {}
|
|
usage = {
|
|
"input_tokens": int(raw_usage.get("input_tokens") or 0),
|
|
"output_tokens": int(raw_usage.get("output_tokens") or 0),
|
|
}
|
|
return result, text, usage, str(payload.get("model") or "claude-account-default")
|
|
|
|
|
|
def generate_with_account(provider: str, prompt: str, audit_path: str) -> dict:
|
|
started = time.monotonic()
|
|
status = command_status(provider)
|
|
if not status["available"] or not status["loggedIn"]:
|
|
audit_model_call(audit_path, provider, "provider_not_logged_in", prompt, 0)
|
|
return {"success": False, "reason": "provider_not_logged_in"}
|
|
if not prompt or len(prompt) > 24000:
|
|
audit_model_call(audit_path, provider, "prompt_length_invalid", prompt, 0)
|
|
return {"success": False, "reason": "prompt_length_invalid"}
|
|
if not model_call_allowed(provider):
|
|
audit_model_call(audit_path, provider, "rate_limited", prompt, 0)
|
|
return {"success": False, "reason": "account_model_rate_limited"}
|
|
gate = MODEL_GATES[provider]
|
|
if not gate.acquire(blocking=False):
|
|
audit_model_call(audit_path, provider, "busy", prompt, 0)
|
|
return {"success": False, "reason": "account_model_busy"}
|
|
try:
|
|
with tempfile.TemporaryDirectory(prefix="casan-account-model-") as directory:
|
|
result, text, usage, model = run_account_command(provider, prompt, directory)
|
|
except (OSError, subprocess.TimeoutExpired):
|
|
audit_model_call(audit_path, provider, "unreachable", prompt, int((time.monotonic() - started) * 1000))
|
|
return {"success": False, "reason": "account_model_unreachable"}
|
|
finally:
|
|
gate.release()
|
|
if result.returncode != 0 or not text:
|
|
audit_model_call(audit_path, provider, "failed", prompt, int((time.monotonic() - started) * 1000))
|
|
return {"success": False, "reason": "account_model_failed"}
|
|
audit_model_call(audit_path, provider, "success", prompt, int((time.monotonic() - started) * 1000))
|
|
return {
|
|
"success": True,
|
|
"provider": provider,
|
|
"model": model,
|
|
"text": text,
|
|
"usage": usage,
|
|
"latency_ms": int((time.monotonic() - started) * 1000),
|
|
}
|
|
|
|
|
|
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.send_header("X-Frame-Options", "DENY")
|
|
self.send_header("Referrer-Policy", "no-referrer")
|
|
self.send_header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'")
|
|
self.end_headers()
|
|
self.wfile.write(encoded)
|
|
|
|
def authorized(self) -> bool:
|
|
host = self.headers.get("Host", "").split(":", 1)[0].lower()
|
|
if host not in {"127.0.0.1", "localhost", "host.docker.internal"}:
|
|
return False
|
|
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 read_json(self) -> dict:
|
|
if self.headers.get("Content-Type", "").split(";", 1)[0].strip().lower() != "application/json":
|
|
return {}
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
except ValueError:
|
|
return {}
|
|
if length < 1 or length > 100000:
|
|
return {}
|
|
try:
|
|
payload = json.loads(self.rfile.read(length).decode("utf-8"))
|
|
except (UnicodeDecodeError, ValueError):
|
|
return {}
|
|
return payload if isinstance(payload, dict) else {}
|
|
|
|
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
|
|
if len(parts) == 4 and parts[:2] == ["v1", "models"] and parts[3] == "generate" and parts[2] in PROVIDERS:
|
|
body = self.read_json()
|
|
result = generate_with_account(parts[2], str(body.get("prompt") or ""), self.server.audit_log) # type: ignore[attr-defined]
|
|
self.send_json(200 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)
|
|
parser.add_argument("--audit-log", default="")
|
|
args = parser.parse_args()
|
|
server = ThreadingHTTPServer((args.bind, args.port), BridgeHandler)
|
|
server.bridge_token = read_token(args.token_file) # type: ignore[attr-defined]
|
|
server.audit_log = os.path.abspath(args.audit_log) if args.audit_log else "" # type: ignore[attr-defined]
|
|
server.daemon_threads = True
|
|
try:
|
|
server.serve_forever()
|
|
except KeyboardInterrupt:
|
|
pass
|
|
finally:
|
|
server.server_close()
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|