diff --git a/nanobot/auth/__init__.py b/nanobot/auth/__init__.py deleted file mode 100644 index ecdc1dc..0000000 --- a/nanobot/auth/__init__.py +++ /dev/null @@ -1,8 +0,0 @@ -"""Authentication modules.""" - -from nanobot.auth.codex import get_codex_token, login_codex_oauth_interactive - -__all__ = [ - "get_codex_token", - "login_codex_oauth_interactive", -] diff --git a/nanobot/auth/codex/__init__.py b/nanobot/auth/codex/__init__.py deleted file mode 100644 index 7a9a39b..0000000 --- a/nanobot/auth/codex/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -"""Codex OAuth module.""" - -from nanobot.auth.codex.flow import get_codex_token, login_codex_oauth_interactive -__all__ = [ - "get_codex_token", - "login_codex_oauth_interactive", -] diff --git a/nanobot/auth/codex/constants.py b/nanobot/auth/codex/constants.py deleted file mode 100644 index 7f20aad..0000000 --- a/nanobot/auth/codex/constants.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Codex OAuth constants.""" - -CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" -AUTHORIZE_URL = "https://auth.openai.com/oauth/authorize" -TOKEN_URL = "https://auth.openai.com/oauth/token" -REDIRECT_URI = "http://localhost:1455/auth/callback" -SCOPE = "openid profile email offline_access" -JWT_CLAIM_PATH = "https://api.openai.com/auth" - -DEFAULT_ORIGINATOR = "nanobot" -TOKEN_FILENAME = "codex.json" -SUCCESS_HTML = ( - "" - "" - "
" - "" - "" - "Authentication successful. Return to your terminal to continue.
" - "" - "" -) diff --git a/nanobot/auth/codex/flow.py b/nanobot/auth/codex/flow.py deleted file mode 100644 index d05feb7..0000000 --- a/nanobot/auth/codex/flow.py +++ /dev/null @@ -1,274 +0,0 @@ -"""Codex OAuth login and token management.""" - -from __future__ import annotations - -import asyncio -import sys -import threading -import time -import urllib.parse -import webbrowser -from typing import Callable - -import httpx - -from nanobot.auth.codex.constants import ( - AUTHORIZE_URL, - CLIENT_ID, - DEFAULT_ORIGINATOR, - REDIRECT_URI, - SCOPE, - TOKEN_URL, -) -from nanobot.auth.codex.models import CodexToken -from nanobot.auth.codex.pkce import ( - _create_state, - _decode_account_id, - _generate_pkce, - _parse_authorization_input, - _parse_token_payload, -) -from nanobot.auth.codex.server import _start_local_server -from nanobot.auth.codex.storage import ( - _FileLock, - _get_token_path, - _load_token_file, - _save_token_file, - _try_import_codex_cli_token, -) - - -async def _exchange_code_for_token_async(code: str, verifier: str) -> CodexToken: - data = { - "grant_type": "authorization_code", - "client_id": CLIENT_ID, - "code": code, - "code_verifier": verifier, - "redirect_uri": REDIRECT_URI, - } - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post( - TOKEN_URL, - data=data, - headers={"Content-Type": "application/x-www-form-urlencoded"}, - ) - if response.status_code != 200: - raise RuntimeError(f"Token exchange failed: {response.status_code} {response.text}") - - payload = response.json() - access, refresh, expires_in = _parse_token_payload(payload, "Token response missing fields") - - account_id = _decode_account_id(access) - return CodexToken( - access=access, - refresh=refresh, - expires=int(time.time() * 1000 + expires_in * 1000), - account_id=account_id, - ) - - -def _refresh_token(refresh_token: str) -> CodexToken: - data = { - "grant_type": "refresh_token", - "refresh_token": refresh_token, - "client_id": CLIENT_ID, - } - with httpx.Client(timeout=30.0) as client: - response = client.post(TOKEN_URL, data=data, headers={"Content-Type": "application/x-www-form-urlencoded"}) - if response.status_code != 200: - raise RuntimeError(f"Token refresh failed: {response.status_code} {response.text}") - - payload = response.json() - access, refresh, expires_in = _parse_token_payload(payload, "Token refresh response missing fields") - - account_id = _decode_account_id(access) - return CodexToken( - access=access, - refresh=refresh, - expires=int(time.time() * 1000 + expires_in * 1000), - account_id=account_id, - ) - - -def get_codex_token() -> CodexToken: - """Get an available token (refresh if needed).""" - token = _load_token_file() or _try_import_codex_cli_token() - if not token: - raise RuntimeError("Codex OAuth credentials not found. Please run the login command.") - - # Refresh 60 seconds early. - now_ms = int(time.time() * 1000) - if token.expires - now_ms > 60 * 1000: - return token - - lock_path = _get_token_path().with_suffix(".lock") - with _FileLock(lock_path): - # Re-read to avoid stale token if another process refreshed it. - token = _load_token_file() or token - now_ms = int(time.time() * 1000) - if token.expires - now_ms > 60 * 1000: - return token - try: - refreshed = _refresh_token(token.refresh) - _save_token_file(refreshed) - return refreshed - except Exception: - # If refresh fails, re-read the file to avoid false negatives. - latest = _load_token_file() - if latest and latest.expires - now_ms > 0: - return latest - raise - - -async def _read_stdin_line() -> str: - loop = asyncio.get_running_loop() - if hasattr(loop, "add_reader") and sys.stdin: - future: asyncio.Future[str] = loop.create_future() - - def _on_readable() -> None: - line = sys.stdin.readline() - if not future.done(): - future.set_result(line) - - try: - loop.add_reader(sys.stdin, _on_readable) - except Exception: - return await loop.run_in_executor(None, sys.stdin.readline) - - try: - return await future - finally: - try: - loop.remove_reader(sys.stdin) - except Exception: - pass - - return await loop.run_in_executor(None, sys.stdin.readline) - - -async def _await_manual_input(print_fn: Callable[[str], None]) -> str: - print_fn("[cyan]Paste the authorization code (or full redirect URL), or wait for the browser callback:[/cyan]") - return await _read_stdin_line() - - -def login_codex_oauth_interactive( - print_fn: Callable[[str], None], - prompt_fn: Callable[[str], str], - originator: str = DEFAULT_ORIGINATOR, -) -> CodexToken: - """Interactive login flow.""" - - async def _login_async() -> CodexToken: - verifier, challenge = _generate_pkce() - state = _create_state() - - params = { - "response_type": "code", - "client_id": CLIENT_ID, - "redirect_uri": REDIRECT_URI, - "scope": SCOPE, - "code_challenge": challenge, - "code_challenge_method": "S256", - "state": state, - "id_token_add_organizations": "true", - "codex_cli_simplified_flow": "true", - "originator": originator, - } - url = f"{AUTHORIZE_URL}?{urllib.parse.urlencode(params)}" - - loop = asyncio.get_running_loop() - code_future: asyncio.Future[str] = loop.create_future() - - def _notify(code_value: str) -> None: - if code_future.done(): - return - loop.call_soon_threadsafe(code_future.set_result, code_value) - - server, server_error = _start_local_server(state, on_code=_notify) - print_fn("[cyan]A browser window will open for login. If it doesn't, open this URL manually:[/cyan]") - print_fn(url) - try: - webbrowser.open(url) - except Exception: - pass - - if not server and server_error: - print_fn( - "[yellow]" - f"Local callback server could not start ({server_error}). " - "You will need to paste the callback URL or authorization code." - "[/yellow]" - ) - - code: str | None = None - try: - if server: - print_fn("[dim]Waiting for browser callback...[/dim]") - - tasks: list[asyncio.Task[object]] = [] - callback_task = asyncio.create_task(asyncio.wait_for(code_future, timeout=120)) - tasks.append(callback_task) - manual_task = asyncio.create_task(_await_manual_input(print_fn)) - tasks.append(manual_task) - - done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED) - for task in pending: - task.cancel() - - for task in done: - try: - result = task.result() - except asyncio.TimeoutError: - result = None - if not result: - continue - if task is manual_task: - parsed_code, parsed_state = _parse_authorization_input(result) - if parsed_state and parsed_state != state: - raise RuntimeError("State validation failed.") - code = parsed_code - else: - code = result - if code: - break - - if not code: - prompt = "Please paste the callback URL or authorization code:" - raw = await loop.run_in_executor(None, prompt_fn, prompt) - parsed_code, parsed_state = _parse_authorization_input(raw) - if parsed_state and parsed_state != state: - raise RuntimeError("State validation failed.") - code = parsed_code - - if not code: - raise RuntimeError("Authorization code not found.") - - print_fn("[dim]Exchanging authorization code for tokens...[/dim]") - token = await _exchange_code_for_token_async(code, verifier) - _save_token_file(token) - return token - finally: - if server: - server.shutdown() - server.server_close() - - try: - asyncio.get_running_loop() - except RuntimeError: - return asyncio.run(_login_async()) - - result: list[CodexToken] = [] - error: list[Exception] = [] - - def _runner() -> None: - try: - result.append(asyncio.run(_login_async())) - except Exception as exc: - error.append(exc) - - thread = threading.Thread(target=_runner) - thread.start() - thread.join() - if error: - raise error[0] - return result[0] diff --git a/nanobot/auth/codex/models.py b/nanobot/auth/codex/models.py deleted file mode 100644 index e3a5f55..0000000 --- a/nanobot/auth/codex/models.py +++ /dev/null @@ -1,15 +0,0 @@ -"""Codex OAuth data models.""" - -from __future__ import annotations - -from dataclasses import dataclass - - -@dataclass -class CodexToken: - """Codex OAuth token data structure.""" - - access: str - refresh: str - expires: int - account_id: str diff --git a/nanobot/auth/codex/pkce.py b/nanobot/auth/codex/pkce.py deleted file mode 100644 index b682386..0000000 --- a/nanobot/auth/codex/pkce.py +++ /dev/null @@ -1,77 +0,0 @@ -"""PKCE and authorization helpers.""" - -from __future__ import annotations - -import base64 -import hashlib -import json -import os -import urllib.parse -from typing import Any - -from nanobot.auth.codex.constants import JWT_CLAIM_PATH - - -def _base64url(data: bytes) -> str: - return base64.urlsafe_b64encode(data).rstrip(b"=").decode("utf-8") - - -def _decode_base64url(data: str) -> bytes: - padding = "=" * (-len(data) % 4) - return base64.urlsafe_b64decode(data + padding) - - -def _generate_pkce() -> tuple[str, str]: - verifier = _base64url(os.urandom(32)) - challenge = _base64url(hashlib.sha256(verifier.encode("utf-8")).digest()) - return verifier, challenge - - -def _create_state() -> str: - return _base64url(os.urandom(16)) - - -def _parse_authorization_input(raw: str) -> tuple[str | None, str | None]: - value = raw.strip() - if not value: - return None, None - try: - url = urllib.parse.urlparse(value) - qs = urllib.parse.parse_qs(url.query) - code = qs.get("code", [None])[0] - state = qs.get("state", [None])[0] - if code: - return code, state - except Exception: - pass - - if "#" in value: - parts = value.split("#", 1) - return parts[0] or None, parts[1] or None - - if "code=" in value: - qs = urllib.parse.parse_qs(value) - return qs.get("code", [None])[0], qs.get("state", [None])[0] - - return value, None - - -def _decode_account_id(access_token: str) -> str: - parts = access_token.split(".") - if len(parts) != 3: - raise ValueError("Invalid JWT token") - payload = json.loads(_decode_base64url(parts[1]).decode("utf-8")) - auth = payload.get(JWT_CLAIM_PATH) or {} - account_id = auth.get("chatgpt_account_id") - if not account_id: - raise ValueError("Failed to extract account_id from token") - return str(account_id) - - -def _parse_token_payload(payload: dict[str, Any], missing_message: str) -> tuple[str, str, int]: - access = payload.get("access_token") - refresh = payload.get("refresh_token") - expires_in = payload.get("expires_in") - if not access or not refresh or not isinstance(expires_in, int): - raise RuntimeError(missing_message) - return access, refresh, expires_in diff --git a/nanobot/auth/codex/server.py b/nanobot/auth/codex/server.py deleted file mode 100644 index f31db19..0000000 --- a/nanobot/auth/codex/server.py +++ /dev/null @@ -1,115 +0,0 @@ -"""Local OAuth callback server.""" - -from __future__ import annotations - -import socket -import threading -import urllib.parse -from http.server import BaseHTTPRequestHandler, HTTPServer -from typing import Any, Callable - -from nanobot.auth.codex.constants import SUCCESS_HTML - - -class _OAuthHandler(BaseHTTPRequestHandler): - """Local callback HTTP handler.""" - - server_version = "NanobotOAuth/1.0" - protocol_version = "HTTP/1.1" - - def do_GET(self) -> None: # noqa: N802 - try: - url = urllib.parse.urlparse(self.path) - if url.path != "/auth/callback": - self.send_response(404) - self.end_headers() - self.wfile.write(b"Not found") - return - - qs = urllib.parse.parse_qs(url.query) - code = qs.get("code", [None])[0] - state = qs.get("state", [None])[0] - - if state != self.server.expected_state: - self.send_response(400) - self.end_headers() - self.wfile.write(b"State mismatch") - return - - if not code: - self.send_response(400) - self.end_headers() - self.wfile.write(b"Missing code") - return - - self.server.code = code - try: - if getattr(self.server, "on_code", None): - self.server.on_code(code) - except Exception: - pass - body = SUCCESS_HTML.encode("utf-8") - self.send_response(200) - self.send_header("Content-Type", "text/html; charset=utf-8") - self.send_header("Content-Length", str(len(body))) - self.send_header("Connection", "close") - self.end_headers() - self.wfile.write(body) - try: - self.wfile.flush() - except Exception: - pass - self.close_connection = True - except Exception: - self.send_response(500) - self.end_headers() - self.wfile.write(b"Internal error") - - def log_message(self, format: str, *args: Any) -> None: # noqa: A003 - # Suppress default logs to avoid noisy output. - return - - -class _OAuthServer(HTTPServer): - """OAuth callback server with state.""" - - def __init__( - self, - server_address: tuple[str, int], - expected_state: str, - on_code: Callable[[str], None] | None = None, - ): - super().__init__(server_address, _OAuthHandler) - self.expected_state = expected_state - self.code: str | None = None - self.on_code = on_code - - -def _start_local_server( - state: str, - on_code: Callable[[str], None] | None = None, -) -> tuple[_OAuthServer | None, str | None]: - """Start a local OAuth callback server on the first available localhost address.""" - try: - addrinfos = socket.getaddrinfo("localhost", 1455, type=socket.SOCK_STREAM) - except OSError as exc: - return None, f"Failed to resolve localhost: {exc}" - - last_error: OSError | None = None - for family, _socktype, _proto, _canonname, sockaddr in addrinfos: - try: - # Support IPv4/IPv6 to avoid missing callbacks when localhost resolves to ::1. - class _AddrOAuthServer(_OAuthServer): - address_family = family - - server = _AddrOAuthServer(sockaddr, state, on_code=on_code) - thread = threading.Thread(target=server.serve_forever, daemon=True) - thread.start() - return server, None - except OSError as exc: - last_error = exc - continue - - if last_error: - return None, f"Local callback server failed to start: {last_error}" - return None, "Local callback server failed to start: unknown error" diff --git a/nanobot/auth/codex/storage.py b/nanobot/auth/codex/storage.py deleted file mode 100644 index 31e5e3d..0000000 --- a/nanobot/auth/codex/storage.py +++ /dev/null @@ -1,118 +0,0 @@ -"""Token storage helpers.""" - -from __future__ import annotations - -import json -import os -import time -from pathlib import Path - -from nanobot.auth.codex.constants import TOKEN_FILENAME -from nanobot.auth.codex.models import CodexToken -from nanobot.utils.helpers import ensure_dir, get_data_path - - -def _get_token_path() -> Path: - auth_dir = ensure_dir(get_data_path() / "auth") - return auth_dir / TOKEN_FILENAME - - -def _load_token_file() -> CodexToken | None: - path = _get_token_path() - if not path.exists(): - return None - try: - data = json.loads(path.read_text(encoding="utf-8")) - return CodexToken( - access=data["access"], - refresh=data["refresh"], - expires=int(data["expires"]), - account_id=data["account_id"], - ) - except Exception: - return None - - -def _save_token_file(token: CodexToken) -> None: - path = _get_token_path() - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text( - json.dumps( - { - "access": token.access, - "refresh": token.refresh, - "expires": token.expires, - "account_id": token.account_id, - }, - ensure_ascii=True, - indent=2, - ), - encoding="utf-8", - ) - try: - os.chmod(path, 0o600) - except Exception: - # Ignore permission setting failures. - pass - - -def _try_import_codex_cli_token() -> CodexToken | None: - codex_path = Path.home() / ".codex" / "auth.json" - if not codex_path.exists(): - return None - try: - data = json.loads(codex_path.read_text(encoding="utf-8")) - tokens = data.get("tokens") or {} - access = tokens.get("access_token") - refresh = tokens.get("refresh_token") - account_id = tokens.get("account_id") - if not access or not refresh or not account_id: - return None - try: - mtime = codex_path.stat().st_mtime - expires = int(mtime * 1000 + 60 * 60 * 1000) - except Exception: - expires = int(time.time() * 1000 + 60 * 60 * 1000) - token = CodexToken( - access=str(access), - refresh=str(refresh), - expires=expires, - account_id=str(account_id), - ) - _save_token_file(token) - return token - except Exception: - return None - - -class _FileLock: - """Simple file lock to reduce concurrent refreshes.""" - - def __init__(self, path: Path): - self._path = path - self._fp = None - - def __enter__(self) -> "_FileLock": - self._path.parent.mkdir(parents=True, exist_ok=True) - self._fp = open(self._path, "a+") - try: - import fcntl - - fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX) - except Exception: - # Non-POSIX or failed lock: continue without locking. - pass - return self - - def __exit__(self, exc_type, exc, tb) -> None: - try: - import fcntl - - fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN) - except Exception: - pass - try: - if self._fp: - self._fp.close() - except Exception: - pass diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index 3be4e23..727c451 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -1,6 +1,7 @@ """CLI commands for nanobot.""" import asyncio +import sys from pathlib import Path import typer @@ -18,6 +19,12 @@ app = typer.Typer( console = Console() +def _safe_print(text: str) -> None: + encoding = sys.stdout.encoding or "utf-8" + safe_text = text.encode(encoding, errors="replace").decode(encoding, errors="replace") + console.print(safe_text) + + def version_callback(value: bool): if value: console.print(f"{__logo__} nanobot v{__version__}") @@ -82,7 +89,7 @@ def login( console.print(f"[red]Unsupported provider: {provider}[/red]") raise typer.Exit(1) - from nanobot.auth.codex import login_codex_oauth_interactive + from oauth_cli_kit import login_oauth_interactive as login_codex_oauth_interactive console.print("[green]Starting OpenAI Codex OAuth login...[/green]") login_codex_oauth_interactive( @@ -180,7 +187,7 @@ def gateway( from nanobot.bus.queue import MessageBus from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from nanobot.auth.codex import get_codex_token + from oauth_cli_kit import get_token as get_codex_token from nanobot.agent.loop import AgentLoop from nanobot.channels.manager import ChannelManager from nanobot.cron.service import CronService @@ -316,7 +323,7 @@ def agent( from nanobot.bus.queue import MessageBus from nanobot.providers.litellm_provider import LiteLLMProvider from nanobot.providers.openai_codex_provider import OpenAICodexProvider - from nanobot.auth.codex import get_codex_token + from oauth_cli_kit import get_token as get_codex_token from nanobot.agent.loop import AgentLoop config = load_config() @@ -361,7 +368,7 @@ def agent( # Single message mode async def run_once(): response = await agent_loop.process_direct(message, session_id) - console.print(f"\n{__logo__} {response}") + _safe_print(f"\n{__logo__} {response}") asyncio.run(run_once()) else: @@ -376,7 +383,7 @@ def agent( continue response = await agent_loop.process_direct(user_input, session_id) - console.print(f"\n{__logo__} {response}\n") + _safe_print(f"\n{__logo__} {response}\n") except KeyboardInterrupt: console.print("\nGoodbye!") break @@ -667,7 +674,7 @@ def cron_run( def status(): """Show nanobot status.""" from nanobot.config.loader import load_config, get_config_path - from nanobot.auth.codex import get_codex_token + from oauth_cli_kit import get_token as get_codex_token config_path = get_config_path() config = load_config() @@ -704,4 +711,3 @@ def status(): if __name__ == "__main__": app() - diff --git a/nanobot/providers/openai_codex_provider.py b/nanobot/providers/openai_codex_provider.py index a23becd..f92db09 100644 --- a/nanobot/providers/openai_codex_provider.py +++ b/nanobot/providers/openai_codex_provider.py @@ -9,7 +9,7 @@ from typing import Any, AsyncGenerator import httpx -from nanobot.auth.codex import get_codex_token +from oauth_cli_kit import get_token as get_codex_token from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api" diff --git a/pyproject.toml b/pyproject.toml index 0c59f66..a1931e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,7 @@ dependencies = [ "websockets>=12.0", "websocket-client>=1.6.0", "httpx>=0.25.0", + "oauth-cli-kit>=0.1.1", "loguru>=0.7.0", "readability-lxml>=0.8.0", "rich>=13.0.0",