refactor: remove Codex OAuth implementation and integrate oauth-cli-kit
This commit is contained in:
parent
b639192e46
commit
42c2d83d70
@ -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",
|
|
||||||
]
|
|
||||||
@ -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",
|
|
||||||
]
|
|
||||||
@ -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 = (
|
|
||||||
"<!doctype html>"
|
|
||||||
"<html lang=\"en\">"
|
|
||||||
"<head>"
|
|
||||||
"<meta charset=\"utf-8\" />"
|
|
||||||
"<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\" />"
|
|
||||||
"<title>Authentication successful</title>"
|
|
||||||
"</head>"
|
|
||||||
"<body>"
|
|
||||||
"<p>Authentication successful. Return to your terminal to continue.</p>"
|
|
||||||
"</body>"
|
|
||||||
"</html>"
|
|
||||||
)
|
|
||||||
@ -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]
|
|
||||||
@ -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
|
|
||||||
@ -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
|
|
||||||
@ -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"
|
|
||||||
@ -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
|
|
||||||
@ -1,6 +1,7 @@
|
|||||||
"""CLI commands for nanobot."""
|
"""CLI commands for nanobot."""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import typer
|
import typer
|
||||||
@ -18,6 +19,12 @@ app = typer.Typer(
|
|||||||
console = Console()
|
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):
|
def version_callback(value: bool):
|
||||||
if value:
|
if value:
|
||||||
console.print(f"{__logo__} nanobot v{__version__}")
|
console.print(f"{__logo__} nanobot v{__version__}")
|
||||||
@ -82,7 +89,7 @@ def login(
|
|||||||
console.print(f"[red]Unsupported provider: {provider}[/red]")
|
console.print(f"[red]Unsupported provider: {provider}[/red]")
|
||||||
raise typer.Exit(1)
|
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]")
|
console.print("[green]Starting OpenAI Codex OAuth login...[/green]")
|
||||||
login_codex_oauth_interactive(
|
login_codex_oauth_interactive(
|
||||||
@ -180,7 +187,7 @@ def gateway(
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.providers.litellm_provider import LiteLLMProvider
|
from nanobot.providers.litellm_provider import LiteLLMProvider
|
||||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
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.agent.loop import AgentLoop
|
||||||
from nanobot.channels.manager import ChannelManager
|
from nanobot.channels.manager import ChannelManager
|
||||||
from nanobot.cron.service import CronService
|
from nanobot.cron.service import CronService
|
||||||
@ -316,7 +323,7 @@ def agent(
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.providers.litellm_provider import LiteLLMProvider
|
from nanobot.providers.litellm_provider import LiteLLMProvider
|
||||||
from nanobot.providers.openai_codex_provider import OpenAICodexProvider
|
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.agent.loop import AgentLoop
|
||||||
|
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@ -361,7 +368,7 @@ def agent(
|
|||||||
# Single message mode
|
# Single message mode
|
||||||
async def run_once():
|
async def run_once():
|
||||||
response = await agent_loop.process_direct(message, session_id)
|
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())
|
asyncio.run(run_once())
|
||||||
else:
|
else:
|
||||||
@ -376,7 +383,7 @@ def agent(
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
response = await agent_loop.process_direct(user_input, session_id)
|
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:
|
except KeyboardInterrupt:
|
||||||
console.print("\nGoodbye!")
|
console.print("\nGoodbye!")
|
||||||
break
|
break
|
||||||
@ -667,7 +674,7 @@ def cron_run(
|
|||||||
def status():
|
def status():
|
||||||
"""Show nanobot status."""
|
"""Show nanobot status."""
|
||||||
from nanobot.config.loader import load_config, get_config_path
|
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_path = get_config_path()
|
||||||
config = load_config()
|
config = load_config()
|
||||||
@ -704,4 +711,3 @@ def status():
|
|||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app()
|
app()
|
||||||
|
|
||||||
|
|||||||
@ -9,7 +9,7 @@ from typing import Any, AsyncGenerator
|
|||||||
|
|
||||||
import httpx
|
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
|
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||||
|
|
||||||
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"
|
DEFAULT_CODEX_BASE_URL = "https://chatgpt.com/backend-api"
|
||||||
|
|||||||
@ -24,6 +24,7 @@ dependencies = [
|
|||||||
"websockets>=12.0",
|
"websockets>=12.0",
|
||||||
"websocket-client>=1.6.0",
|
"websocket-client>=1.6.0",
|
||||||
"httpx>=0.25.0",
|
"httpx>=0.25.0",
|
||||||
|
"oauth-cli-kit>=0.1.1",
|
||||||
"loguru>=0.7.0",
|
"loguru>=0.7.0",
|
||||||
"readability-lxml>=0.8.0",
|
"readability-lxml>=0.8.0",
|
||||||
"rich>=13.0.0",
|
"rich>=13.0.0",
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user