mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-10 22:38:40 +03:00
273 lines
9.2 KiB
Python
273 lines
9.2 KiB
Python
"""WebUI adapter around oauth-cli-kit's interactive Codex login."""
|
|
|
|
# oauth-cli-kit does not publish type stubs.
|
|
# pyright: reportMissingTypeStubs=false
|
|
|
|
from __future__ import annotations
|
|
|
|
import hmac
|
|
import queue
|
|
import re
|
|
import threading
|
|
import time
|
|
from concurrent.futures import Future
|
|
from contextlib import suppress
|
|
from urllib.parse import parse_qs, urlsplit
|
|
|
|
from oauth_cli_kit import login_oauth_interactive
|
|
from oauth_cli_kit.models import OAuthToken
|
|
from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER
|
|
|
|
_AUTHORIZATION_URL_TIMEOUT_S = 5.0
|
|
_CALLBACK = urlsplit(OPENAI_CODEX_PROVIDER.redirect_uri)
|
|
_CALLBACK_HOSTS = {"localhost", "127.0.0.1", "::1"}
|
|
_TOKEN_EXCHANGE_STATUS = re.compile(r"Token exchange failed:\s*(\d{3})\b")
|
|
|
|
|
|
class OpenAICodexOAuthError(RuntimeError):
|
|
"""An actionable Codex OAuth failure that contains no credential material."""
|
|
|
|
|
|
class OpenAICodexOAuthInputError(OpenAICodexOAuthError):
|
|
"""A recoverable error in a callback URL pasted by the user."""
|
|
|
|
|
|
class OpenAICodexOAuthLoginFlow:
|
|
"""Expose oauth-cli-kit's blocking prompt as a two-stage WebUI flow."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
proxy: str | None,
|
|
timeout_s: float,
|
|
open_browser: bool,
|
|
) -> None:
|
|
self.authorization_url = ""
|
|
self._expected_state = ""
|
|
self._proxy = proxy
|
|
self._open_browser = open_browser
|
|
self._expires_at = time.monotonic() + timeout_s
|
|
self._callback_input: queue.Queue[str] = queue.Queue(maxsize=1)
|
|
self._result: Future[OAuthToken] = Future()
|
|
self._ready = threading.Event()
|
|
self._submission_lock = threading.Lock()
|
|
self._submitted = False
|
|
self._thread = threading.Thread(
|
|
target=self._run,
|
|
name="nanobot-openai-codex-oauth",
|
|
daemon=True,
|
|
)
|
|
|
|
@property
|
|
def expired(self) -> bool:
|
|
return time.monotonic() >= self._expires_at
|
|
|
|
@property
|
|
def remaining_seconds(self) -> int:
|
|
return max(0, int(self._expires_at - time.monotonic()))
|
|
|
|
def start(self) -> OpenAICodexOAuthLoginFlow:
|
|
self._thread.start()
|
|
wait_s = min(
|
|
_AUTHORIZATION_URL_TIMEOUT_S,
|
|
max(0.0, self._expires_at - time.monotonic()),
|
|
)
|
|
if not self._ready.wait(wait_s):
|
|
error = OpenAICodexOAuthError(
|
|
"OpenAI Codex sign-in could not create an authorization URL."
|
|
)
|
|
self._fail(error)
|
|
raise error
|
|
if self._result.done():
|
|
self._result.result()
|
|
if self.authorization_url:
|
|
return self
|
|
error = OpenAICodexOAuthError(
|
|
"OpenAI Codex sign-in returned no authorization URL."
|
|
)
|
|
self._fail(error)
|
|
raise error
|
|
|
|
def complete(self, callback_url: str | None = None) -> OAuthToken | None:
|
|
"""Submit a full callback URL, or return ``None`` while waiting for one."""
|
|
if self._result.done():
|
|
return self._result.result()
|
|
if self.expired:
|
|
error = OpenAICodexOAuthError(
|
|
"OpenAI Codex sign-in expired. Start a new sign-in flow."
|
|
)
|
|
self._fail(error)
|
|
raise error
|
|
if callback_url is None:
|
|
return None
|
|
|
|
callback_state, authorization_failed = _validate_callback_url(callback_url)
|
|
if not hmac.compare_digest(callback_state, self._expected_state):
|
|
raise OpenAICodexOAuthInputError(
|
|
"The callback URL does not belong to this sign-in flow. Copy the latest URL."
|
|
)
|
|
if authorization_failed:
|
|
error = OpenAICodexOAuthError(
|
|
"OpenAI Codex sign-in was not completed by the authorization server."
|
|
)
|
|
self._fail(error)
|
|
raise error
|
|
|
|
with self._submission_lock:
|
|
if self._submitted:
|
|
return None
|
|
self._submitted = True
|
|
try:
|
|
self._callback_input.put_nowait(callback_url.strip())
|
|
except queue.Full:
|
|
return None
|
|
return self._result.result() if self._result.done() else None
|
|
|
|
def cancel(self) -> None:
|
|
"""Unblock an abandoned interactive login."""
|
|
self._fail(OpenAICodexOAuthError("OpenAI Codex sign-in was cancelled."))
|
|
if threading.current_thread() is not self._thread:
|
|
self._thread.join(timeout=0.5)
|
|
|
|
def _run(self) -> None:
|
|
try:
|
|
token = login_oauth_interactive(
|
|
print_fn=self._capture_output,
|
|
prompt_fn=self._prompt_for_callback,
|
|
provider=OPENAI_CODEX_PROVIDER,
|
|
proxy=self._proxy,
|
|
open_browser=self._open_browser,
|
|
)
|
|
except Exception as exc:
|
|
with suppress(Exception):
|
|
self._result.set_exception(_safe_login_error(exc))
|
|
else:
|
|
with suppress(Exception):
|
|
self._result.set_result(token)
|
|
finally:
|
|
self._ready.set()
|
|
|
|
def _capture_output(self, message: str) -> None:
|
|
raw = str(message)
|
|
start = raw.find(OPENAI_CODEX_PROVIDER.authorize_url)
|
|
if start < 0:
|
|
return
|
|
candidate = raw[start:].split(maxsplit=1)[0]
|
|
state = _first(parse_qs(urlsplit(candidate).query), "state")
|
|
if not state:
|
|
return
|
|
self.authorization_url = candidate
|
|
self._expected_state = state
|
|
self._ready.set()
|
|
|
|
def _prompt_for_callback(self, _prompt: str) -> str:
|
|
remaining = max(0.0, self._expires_at - time.monotonic())
|
|
try:
|
|
value = self._callback_input.get(timeout=remaining)
|
|
except queue.Empty as exc:
|
|
raise OpenAICodexOAuthError(
|
|
"OpenAI Codex sign-in expired. Start a new sign-in flow."
|
|
) from exc
|
|
if not value:
|
|
error = self._result.exception() if self._result.done() else None
|
|
if error is not None:
|
|
raise error
|
|
raise OpenAICodexOAuthError("OpenAI Codex sign-in was cancelled.")
|
|
return value
|
|
|
|
def _fail(self, error: OpenAICodexOAuthError) -> None:
|
|
try:
|
|
self._result.set_exception(error)
|
|
except Exception:
|
|
pass
|
|
else:
|
|
with suppress(queue.Full):
|
|
self._callback_input.put_nowait("")
|
|
self._ready.set()
|
|
|
|
|
|
def start_openai_codex_oauth_login(
|
|
*,
|
|
proxy: str | None = None,
|
|
timeout_s: float = 600,
|
|
open_browser: bool = True,
|
|
) -> OpenAICodexOAuthLoginFlow:
|
|
"""Start a non-blocking wrapper around oauth-cli-kit's Codex login."""
|
|
return OpenAICodexOAuthLoginFlow(
|
|
proxy=proxy,
|
|
timeout_s=timeout_s,
|
|
open_browser=open_browser,
|
|
).start()
|
|
|
|
|
|
def complete_openai_codex_oauth_login(
|
|
flow: OpenAICodexOAuthLoginFlow,
|
|
callback_url: str | None = None,
|
|
) -> OAuthToken | None:
|
|
"""Complete a pending Codex login from a full callback URL."""
|
|
return flow.complete(callback_url)
|
|
|
|
|
|
def _validate_callback_url(raw: str) -> tuple[str, bool]:
|
|
value = raw.strip()
|
|
if not value:
|
|
raise OpenAICodexOAuthInputError("Paste the full callback URL from your browser.")
|
|
try:
|
|
parsed = urlsplit(value)
|
|
port = parsed.port
|
|
except ValueError as exc:
|
|
raise OpenAICodexOAuthInputError(
|
|
"The callback URL is invalid. Copy the full URL from your browser's address bar."
|
|
) from exc
|
|
if (
|
|
parsed.scheme != _CALLBACK.scheme
|
|
or parsed.hostname not in _CALLBACK_HOSTS
|
|
or port != _CALLBACK.port
|
|
or parsed.path != _CALLBACK.path
|
|
or parsed.username is not None
|
|
or parsed.password is not None
|
|
):
|
|
raise OpenAICodexOAuthInputError(
|
|
f"Paste the full callback URL from your browser ({OPENAI_CODEX_PROVIDER.redirect_uri}?...)."
|
|
)
|
|
params = parse_qs(parsed.query)
|
|
code = _first(params, "code")
|
|
state = _first(params, "state")
|
|
error = _first(params, "error")
|
|
if not state:
|
|
raise OpenAICodexOAuthInputError(
|
|
"The callback URL is missing OAuth state. Copy the entire browser address."
|
|
)
|
|
if not code and not error:
|
|
raise OpenAICodexOAuthInputError(
|
|
"The callback URL has no authorization result. Finish signing in, then copy it again."
|
|
)
|
|
return state, error is not None
|
|
|
|
|
|
def _safe_login_error(exc: Exception) -> OpenAICodexOAuthError:
|
|
if isinstance(exc, OpenAICodexOAuthError):
|
|
return exc
|
|
message = str(exc).strip()
|
|
if message == "State validation failed.":
|
|
return OpenAICodexOAuthError(
|
|
"OpenAI Codex sign-in failed because the OAuth state did not match."
|
|
)
|
|
if message == "Authorization code not found.":
|
|
return OpenAICodexOAuthError(
|
|
"OpenAI Codex sign-in returned no authorization code."
|
|
)
|
|
status = _TOKEN_EXCHANGE_STATUS.search(message)
|
|
if status:
|
|
return OpenAICodexOAuthError(
|
|
f"OpenAI Codex OAuth token exchange failed with HTTP {status.group(1)}."
|
|
)
|
|
return OpenAICodexOAuthError(
|
|
f"OpenAI Codex sign-in failed ({type(exc).__name__})."
|
|
)
|
|
|
|
|
|
def _first(params: dict[str, list[str]], key: str) -> str | None:
|
|
values = params.get(key)
|
|
return values[0] if values else None
|