diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index f82ab7010..b871f6639 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -150,7 +150,7 @@ If you need a known-good snippet instead of diagnosis, use [`provider-cookbook.m | Bedrock validation error | Check AWS region, credentials, model access, model ID, and whether the model supports Converse. | | OAuth provider fails | Run the matching login command: `openai-codex`, `xai-grok`, or `github-copilot`, normally with `--set-main`. | | Codex OAuth needs a proxy | Set `providers.openaiCodex.proxy` before running the login command. The proxy applies to login, token refresh, and Codex API requests. | -| Codex login runs on a remote/headless machine | Open the printed URL in a local browser, then paste the final `http://localhost:1455/auth/callback?...` URL back into the terminal. | +| Codex login runs on a remote/headless machine | In the WebUI, open ChatGPT in your local browser; when the localhost callback page cannot load, copy the full `http://localhost:1455/auth/callback?...` URL from the address bar and paste it into the WebUI dialog. From the CLI, open the printed URL locally and paste the same callback URL back into the terminal. | | Codex login runs in Docker | Start the container with `docker run -it` so the OAuth flow has an interactive terminal. | | Codex says a model is not supported with a ChatGPT account | Use provider `openai_codex` with a Codex model such as `openai-codex/gpt-5.6-sol`. Do not use the direct-API `openai/...` prefix with Codex OAuth. | | Config says `providers.openai_codex` conflicts with the built-in provider | Under `providers`, keep only the canonical `openaiCodex` settings key and remove a duplicate `openai_codex` key. A model preset's `provider` value remains `openai_codex`. | diff --git a/nanobot/providers/openai_codex_oauth.py b/nanobot/providers/openai_codex_oauth.py new file mode 100644 index 000000000..ffc20cefa --- /dev/null +++ b/nanobot/providers/openai_codex_oauth.py @@ -0,0 +1,272 @@ +"""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 diff --git a/nanobot/webui/settings_api.py b/nanobot/webui/settings_api.py index 038938913..19cecd38e 100644 --- a/nanobot/webui/settings_api.py +++ b/nanobot/webui/settings_api.py @@ -131,10 +131,10 @@ _IMAGE_GENERATION_ASPECT_RATIOS = { } _CONTEXT_WINDOW_TOKEN_OPTIONS = {65_536, 200_000, 262_144, 500_000, 1_048_576} _OAUTH_PROXY_PROVIDERS = {"openai_codex", "xai_grok"} -_XAI_WEBUI_OAUTH_TIMEOUT_S = 600 -_XAI_WEBUI_OAUTH_MAX_FLOWS = 8 -_xai_webui_oauth_flows: dict[str, Any] = {} -_xai_webui_oauth_flows_lock = threading.Lock() +_WEBUI_OAUTH_TIMEOUT_S = 600 +_WEBUI_OAUTH_MAX_FLOWS = 8 +_webui_oauth_flows: dict[str, tuple[str, Any]] = {} +_webui_oauth_flows_lock = threading.Lock() _MODEL_CONFIGURATION_SLUG_RE = re.compile(r"[^a-z0-9_-]+") _ENV_REF_RE = re.compile(r"\$\{([A-Za-z_][A-Za-z0-9_]*)\}") @@ -1810,7 +1810,7 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]: if spec.name == "openai_codex": try: - from oauth_cli_kit import get_token, login_oauth_interactive + from nanobot.providers.openai_codex_oauth import start_openai_codex_oauth_login except ImportError: raise WebUISettingsError( "oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500 @@ -1820,19 +1820,30 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]: proxy = resolve_config_env_vars(load_config()).providers.openai_codex.proxy or None except ValueError as e: raise WebUISettingsError(str(e), status=400) from e - token = None - with suppress(Exception): - token = get_token(proxy=proxy) - if not (token and token.access): - messages: list[str] = [] - token = login_oauth_interactive( - print_fn=lambda message: messages.append(str(message)), - prompt_fn=lambda _prompt: "", + remote_browser_value = _query_first(query, "remote_browser") + remote_browser = ( + _parse_bool(remote_browser_value, "remote_browser") + if remote_browser_value is not None + else False + ) + try: + flow = start_openai_codex_oauth_login( proxy=proxy, + timeout_s=_WEBUI_OAUTH_TIMEOUT_S, + open_browser=not remote_browser, ) - if not (token and token.access): - raise WebUISettingsError("OAuth login failed", status=401) - return settings_payload() + except Exception as e: + raise WebUISettingsError(f"OpenAI Codex OAuth login failed: {e}", status=502) from e + flow_id = secrets.token_urlsafe(24) + _register_webui_oauth_flow(spec.name, flow_id, flow) + return { + "status": "authorization_required", + "provider": spec.name, + "flow_id": flow_id, + "authorization_url": flow.authorization_url, + "expires_in": flow.remaining_seconds, + "completion_input": "callback_url", + } if spec.name == "github_copilot": try: @@ -1862,18 +1873,19 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]: try: flow = start_xai_oauth_login( proxy=proxy, - timeout_s=_XAI_WEBUI_OAUTH_TIMEOUT_S, + timeout_s=_WEBUI_OAUTH_TIMEOUT_S, ) except Exception as e: raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e flow_id = secrets.token_urlsafe(24) - _register_xai_webui_oauth_flow(flow_id, flow) + _register_webui_oauth_flow(spec.name, flow_id, flow) return { "status": "authorization_required", "provider": spec.name, "flow_id": flow_id, "authorization_url": flow.authorization_url, "expires_in": flow.remaining_seconds, + "completion_input": "authorization_code", } raise WebUISettingsError("OAuth login is not supported for this provider") @@ -1881,34 +1893,47 @@ def login_oauth_provider(query: QueryParams) -> dict[str, Any]: def complete_oauth_provider( query: QueryParams, - authorization_code: str | None = None, + authorization_response: str | None = None, ) -> dict[str, Any]: provider_name = (_query_first(query, "provider") or "").strip() flow_id = (_query_first(query, "flow_id") or "").strip() spec = find_by_name(provider_name) - if spec is None or spec.name != "xai_grok": + if spec is None or spec.name not in {"openai_codex", "xai_grok"}: raise WebUISettingsError("OAuth completion is not supported for this provider") if not flow_id: raise WebUISettingsError("flow_id is required") - flow = _get_xai_webui_oauth_flow(flow_id) + flow = _get_webui_oauth_flow(spec.name, flow_id) if flow is None: - raise WebUISettingsError("xAI sign-in expired. Start again.", status=410) - - from nanobot.providers.xai_oauth import complete_xai_oauth_login + raise WebUISettingsError(f"{spec.label} sign-in expired. Start again.", status=410) try: - token = complete_xai_oauth_login(flow, authorization_code) + if spec.name == "openai_codex": + from nanobot.providers.openai_codex_oauth import ( + OpenAICodexOAuthInputError, + complete_openai_codex_oauth_login, + ) + + try: + token = complete_openai_codex_oauth_login(flow, authorization_response) + except OpenAICodexOAuthInputError as e: + raise WebUISettingsError(str(e), status=400) from e + else: + from nanobot.providers.xai_oauth import complete_xai_oauth_login + + token = complete_xai_oauth_login(flow, authorization_response) + except WebUISettingsError: + raise except Exception as e: - _remove_xai_webui_oauth_flow(flow_id, flow) - raise WebUISettingsError(f"xAI OAuth login failed: {e}", status=502) from e + _remove_webui_oauth_flow(spec.name, flow_id, flow) + raise WebUISettingsError(f"{spec.label} OAuth login failed: {e}", status=502) from e if token is None: return { "status": "pending", "provider": spec.name, "flow_id": flow_id, } - _remove_xai_webui_oauth_flow(flow_id, flow, cancel=False) + _remove_webui_oauth_flow(spec.name, flow_id, flow, cancel=False) if not token.access: raise WebUISettingsError("OAuth login failed", status=401) return settings_payload() @@ -1930,6 +1955,7 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]: raise WebUISettingsError( "oauth_cli_kit not installed. Run: pip install oauth-cli-kit", status=500 ) from None + _clear_webui_oauth_flows(spec.name) token_path = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).get_token_path() elif spec.name == "github_copilot": try: @@ -1942,7 +1968,7 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]: elif spec.name == "xai_grok": from nanobot.providers.xai_oauth import logout_xai_oauth - _clear_xai_webui_oauth_flows() + _clear_webui_oauth_flows(spec.name) logout_xai_oauth() return settings_payload() else: @@ -1954,47 +1980,60 @@ def logout_oauth_provider(query: QueryParams) -> dict[str, Any]: return settings_payload() -def _register_xai_webui_oauth_flow(flow_id: str, flow: Any) -> None: +def _register_webui_oauth_flow(provider_name: str, flow_id: str, flow: Any) -> None: discarded: list[Any] = [] - with _xai_webui_oauth_flows_lock: - for existing_id, existing in list(_xai_webui_oauth_flows.items()): + with _webui_oauth_flows_lock: + for existing_id, (_provider_name, existing) in list(_webui_oauth_flows.items()): if existing.expired: - discarded.append(_xai_webui_oauth_flows.pop(existing_id)) - while len(_xai_webui_oauth_flows) >= _XAI_WEBUI_OAUTH_MAX_FLOWS: - oldest_id = next(iter(_xai_webui_oauth_flows)) - discarded.append(_xai_webui_oauth_flows.pop(oldest_id)) - _xai_webui_oauth_flows[flow_id] = flow + discarded.append(_webui_oauth_flows.pop(existing_id)[1]) + while len(_webui_oauth_flows) >= _WEBUI_OAUTH_MAX_FLOWS: + oldest_id = next(iter(_webui_oauth_flows)) + discarded.append(_webui_oauth_flows.pop(oldest_id)[1]) + _webui_oauth_flows[flow_id] = (provider_name, flow) for existing in discarded: existing.cancel() -def _get_xai_webui_oauth_flow(flow_id: str) -> Any | None: - with _xai_webui_oauth_flows_lock: - flow = _xai_webui_oauth_flows.get(flow_id) - if flow is None or not flow.expired: +def _get_webui_oauth_flow(provider_name: str, flow_id: str) -> Any | None: + with _webui_oauth_flows_lock: + registered = _webui_oauth_flows.get(flow_id) + if registered is None or registered[0] != provider_name: + return None + flow = registered[1] + if not flow.expired: return flow - _xai_webui_oauth_flows.pop(flow_id, None) + _webui_oauth_flows.pop(flow_id, None) flow.cancel() return None -def _remove_xai_webui_oauth_flow( +def _remove_webui_oauth_flow( + provider_name: str, flow_id: str, flow: Any, *, cancel: bool = True, ) -> None: - with _xai_webui_oauth_flows_lock: - if _xai_webui_oauth_flows.get(flow_id) is flow: - _xai_webui_oauth_flows.pop(flow_id) + with _webui_oauth_flows_lock: + registered = _webui_oauth_flows.get(flow_id) + if ( + registered is not None + and registered[0] == provider_name + and registered[1] is flow + ): + _webui_oauth_flows.pop(flow_id) if cancel: flow.cancel() -def _clear_xai_webui_oauth_flows() -> None: - with _xai_webui_oauth_flows_lock: - flows = list(_xai_webui_oauth_flows.values()) - _xai_webui_oauth_flows.clear() +def _clear_webui_oauth_flows(provider_name: str) -> None: + with _webui_oauth_flows_lock: + flow_ids = [ + flow_id + for flow_id, (registered_provider, _flow) in _webui_oauth_flows.items() + if registered_provider == provider_name + ] + flows = [_webui_oauth_flows.pop(flow_id)[1] for flow_id in flow_ids] for flow in flows: flow.cancel() diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index aa451bc96..98cab4e7c 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -85,7 +85,8 @@ _CHANNEL_VALUES_HEADER_MAX_BYTES = 64 * 1024 _API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values" _API_SERVICE_VALUES_HEADER_MAX_BYTES = 8 * 1024 _OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code" -_OAUTH_CODE_HEADER_MAX_BYTES = 8 * 1024 +_OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback" +_OAUTH_RESPONSE_HEADER_MAX_BYTES = 8 * 1024 _SKIP_FIELD = object() _CHANNEL_CONNECT_ACTIONS = frozenset({"start", "poll", "cancel"}) @@ -471,16 +472,22 @@ class WebUISettingsRouter: if action == "login": payload = await asyncio.to_thread(login_oauth_provider, query) elif action == "complete": - authorization_code = case_insensitive_header( + authorization_response = case_insensitive_header( + request.headers, + _OAUTH_CALLBACK_HEADER, + ) or case_insensitive_header( request.headers, _OAUTH_CODE_HEADER, ) - if len(authorization_code.encode("utf-8")) > _OAUTH_CODE_HEADER_MAX_BYTES: - raise WebUISettingsError("OAuth authorization code is too large") + if ( + len(authorization_response.encode("utf-8")) + > _OAUTH_RESPONSE_HEADER_MAX_BYTES + ): + raise WebUISettingsError("OAuth authorization response is too large") payload = await asyncio.to_thread( complete_oauth_provider, query, - authorization_code or None, + authorization_response or None, ) else: payload = await asyncio.to_thread(logout_oauth_provider, query) diff --git a/tests/providers/test_openai_codex_oauth.py b/tests/providers/test_openai_codex_oauth.py new file mode 100644 index 000000000..1e58c8506 --- /dev/null +++ b/tests/providers/test_openai_codex_oauth.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +import time +from collections.abc import Callable +from urllib.parse import parse_qs, urlencode, urlsplit + +import pytest +from oauth_cli_kit.models import OAuthToken + +import nanobot.providers.openai_codex_oauth as codex_oauth +from nanobot.providers.openai_codex_oauth import ( + OpenAICodexOAuthError, + OpenAICodexOAuthInputError, + complete_openai_codex_oauth_login, + start_openai_codex_oauth_login, +) + + +def _authorization_url(state: str = "expected-state") -> str: + return f"{codex_oauth.OPENAI_CODEX_PROVIDER.authorize_url}?{urlencode({'state': state})}" + + +def _wait_for_completion(flow) -> OAuthToken: + deadline = time.monotonic() + 1 + while time.monotonic() < deadline: + token = complete_openai_codex_oauth_login(flow) + if token is not None: + return token + time.sleep(0.01) + pytest.fail("OAuth flow did not finish") + + +def _fake_interactive_login( + captured: dict[str, object], + *, + error: Exception | None = None, +) -> Callable[..., OAuthToken]: + def login( + *, + print_fn, + prompt_fn, + provider, + proxy, + open_browser, + ) -> OAuthToken: + captured.update( + provider=provider, + proxy=proxy, + open_browser=open_browser, + ) + print_fn("Open this URL:") + print_fn(_authorization_url()) + if not open_browser: + captured["callback_url"] = prompt_fn("Paste callback URL") + if error is not None: + raise error + return OAuthToken( + access="access-token", + refresh="refresh-token", + expires=2_000_000_000_000, + account_id="acct-test", + ) + + return login + + +def test_authorization_url_comes_from_oauth_cli_kit() -> None: + flow = start_openai_codex_oauth_login( + timeout_s=2, + open_browser=False, + ) + try: + params = parse_qs(urlsplit(flow.authorization_url).query) + assert params["response_type"] == ["code"] + assert params["client_id"] == [codex_oauth.OPENAI_CODEX_PROVIDER.client_id] + assert params["redirect_uri"] == [codex_oauth.OPENAI_CODEX_PROVIDER.redirect_uri] + assert params["code_challenge_method"] == ["S256"] + assert params["code_challenge"] + assert params["state"] + finally: + flow.cancel() + + +def test_local_flow_delegates_browser_and_callback_to_public_oauth_cli_kit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + monkeypatch.setattr( + codex_oauth, + "login_oauth_interactive", + _fake_interactive_login(captured), + ) + flow = start_openai_codex_oauth_login(timeout_s=5) + + try: + token = _wait_for_completion(flow) + finally: + flow.cancel() + + assert token.account_id == "acct-test" + assert captured == { + "provider": codex_oauth.OPENAI_CODEX_PROVIDER, + "proxy": None, + "open_browser": True, + } + + +def test_remote_flow_delegates_pasted_callback_to_public_oauth_cli_kit( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + monkeypatch.setattr( + codex_oauth, + "login_oauth_interactive", + _fake_interactive_login(captured), + ) + flow = start_openai_codex_oauth_login( + proxy="http://127.0.0.1:7890", + timeout_s=5, + open_browser=False, + ) + callback_url = ( + "http://localhost:1455/auth/callback?" + + urlencode({"code": "authorization-code", "state": "expected-state"}) + ) + try: + assert complete_openai_codex_oauth_login(flow) is None + with pytest.raises(OpenAICodexOAuthInputError, match="full callback URL"): + complete_openai_codex_oauth_login(flow, "authorization-code") + token = complete_openai_codex_oauth_login(flow, callback_url) + if token is None: + token = _wait_for_completion(flow) + finally: + flow.cancel() + + assert token is not None + assert token.account_id == "acct-test" + assert captured == { + "provider": codex_oauth.OPENAI_CODEX_PROVIDER, + "proxy": "http://127.0.0.1:7890", + "open_browser": False, + "callback_url": callback_url, + } + + +def test_remote_flow_rejects_callback_from_another_login( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + monkeypatch.setattr( + codex_oauth, + "login_oauth_interactive", + _fake_interactive_login(captured), + ) + flow = start_openai_codex_oauth_login( + timeout_s=5, + open_browser=False, + ) + callback_url = ( + "http://localhost:1455/auth/callback?" + + urlencode({"code": "authorization-code", "state": "wrong-state"}) + ) + try: + with pytest.raises(OpenAICodexOAuthInputError, match="does not belong"): + complete_openai_codex_oauth_login(flow, callback_url) + assert "callback_url" not in captured + finally: + flow.cancel() + + +def test_remote_flow_reports_authorization_denial_without_exchanging_code( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + monkeypatch.setattr( + codex_oauth, + "login_oauth_interactive", + _fake_interactive_login(captured), + ) + flow = start_openai_codex_oauth_login( + timeout_s=5, + open_browser=False, + ) + callback_url = ( + "http://localhost:1455/auth/callback?" + + urlencode({"error": "access_denied", "state": "expected-state"}) + ) + try: + with pytest.raises(OpenAICodexOAuthError, match="authorization server"): + complete_openai_codex_oauth_login(flow, callback_url) + finally: + flow.cancel() + + assert "callback_url" not in captured + + +def test_dependency_error_is_bounded_and_does_not_expose_callback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + captured: dict[str, object] = {} + monkeypatch.setattr( + codex_oauth, + "login_oauth_interactive", + _fake_interactive_login( + captured, + error=RuntimeError("Token exchange failed: 400 secret-code upstream-body"), + ), + ) + flow = start_openai_codex_oauth_login( + timeout_s=5, + open_browser=False, + ) + callback_url = ( + "http://localhost:1455/auth/callback?" + + urlencode({"code": "secret-code", "state": "expected-state"}) + ) + try: + with pytest.raises(OpenAICodexOAuthError) as exc: + token = complete_openai_codex_oauth_login(flow, callback_url) + if token is None: + _wait_for_completion(flow) + finally: + flow.cancel() + + assert str(exc.value) == "OpenAI Codex OAuth token exchange failed with HTTP 400." + assert "secret-code" not in str(exc.value) + + +def test_remote_flow_expires_while_waiting_for_callback( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr( + codex_oauth, + "login_oauth_interactive", + _fake_interactive_login({}), + ) + flow = start_openai_codex_oauth_login( + timeout_s=0.05, + open_browser=False, + ) + try: + time.sleep(0.08) + with pytest.raises(OpenAICodexOAuthError, match="expired"): + complete_openai_codex_oauth_login(flow) + finally: + flow.cancel() diff --git a/tests/webui/test_settings_api.py b/tests/webui/test_settings_api.py index 7cd2cce9f..64af3ec28 100644 --- a/tests/webui/test_settings_api.py +++ b/tests/webui/test_settings_api.py @@ -12,6 +12,7 @@ from nanobot.config.schema import Config, InlineFallbackConfig, ModelPresetConfi from nanobot.providers.registry import find_by_name from nanobot.webui.settings_api import ( WebUISettingsError, + _clear_webui_oauth_flows, _docs_version, _model_catalog_kind, _oauth_provider_status, @@ -1454,25 +1455,114 @@ def test_openai_codex_oauth_login_passes_configured_proxy( ) monkeypatch.setenv("CODEX_PROXY_TEST", proxy) monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + captured: dict[str, object] = {} - import oauth_cli_kit + class FakeFlow: + authorization_url = "https://auth.openai.com/oauth/authorize?state=test" + remaining_seconds = 600 + expired = False - captured: dict[str, str | None] = {} + def cancel(self) -> None: + captured["cancelled"] = True - def fake_get_token(*, proxy=None): - captured["get_proxy"] = proxy - raise RuntimeError("no-token") + def fake_start(*, proxy=None, timeout_s=None, open_browser=None): + captured.update( + proxy=proxy, + timeout_s=timeout_s, + open_browser=open_browser, + ) + return FakeFlow() - def fake_login(*, print_fn, prompt_fn, proxy=None): - captured["login_proxy"] = proxy - return SimpleNamespace(access="access-token", account_id="acct-test") + monkeypatch.setattr( + "nanobot.providers.openai_codex_oauth.start_openai_codex_oauth_login", + fake_start, + ) - monkeypatch.setattr(oauth_cli_kit, "get_token", fake_get_token) - monkeypatch.setattr(oauth_cli_kit, "login_oauth_interactive", fake_login) + payload = login_oauth_provider({"provider": ["openai-codex"]}) - login_oauth_provider({"provider": ["openai-codex"]}) + assert captured == { + "proxy": proxy, + "timeout_s": 600, + "open_browser": True, + } + assert payload["status"] == "authorization_required" + assert payload["provider"] == "openai_codex" + assert payload["authorization_url"] == FakeFlow.authorization_url + assert payload["completion_input"] == "callback_url" - assert captured == {"get_proxy": proxy, "login_proxy": proxy} + callbacks: list[str | None] = [] + + def fake_complete(_flow, callback): + callbacks.append(callback) + if callback is None: + return None + return SimpleNamespace(access="access-token") + + monkeypatch.setattr( + "nanobot.providers.openai_codex_oauth.complete_openai_codex_oauth_login", + fake_complete, + ) + monkeypatch.setattr( + "nanobot.webui.settings_api.settings_payload", + lambda: {"settings": "ready"}, + ) + + pending = complete_oauth_provider( + {"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]}, + ) + completed = complete_oauth_provider( + {"provider": ["openai-codex"], "flow_id": [payload["flow_id"]]}, + "http://localhost:1455/auth/callback?code=secret&state=test", + ) + + assert pending == { + "status": "pending", + "provider": "openai_codex", + "flow_id": payload["flow_id"], + } + assert completed == {"settings": "ready"} + assert callbacks == [ + None, + "http://localhost:1455/auth/callback?code=secret&state=test", + ] + + +def test_openai_codex_remote_login_uses_headless_dependency_mode( + tmp_path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + config_path = tmp_path / "config.json" + save_config(Config(), config_path) + monkeypatch.setattr("nanobot.config.loader._current_config_path", config_path) + captured: dict[str, object] = {} + + class FakeFlow: + authorization_url = "https://auth.openai.com/oauth/authorize?state=test" + remaining_seconds = 600 + expired = False + + def cancel(self) -> None: + captured["cancelled"] = True + + def fake_start(**kwargs): + captured.update(kwargs) + return FakeFlow() + + monkeypatch.setattr( + "nanobot.providers.openai_codex_oauth.start_openai_codex_oauth_login", + fake_start, + ) + + try: + payload = login_oauth_provider( + {"provider": ["openai-codex"], "remote_browser": ["true"]} + ) + finally: + _clear_webui_oauth_flows("openai_codex") + + assert payload["completion_input"] == "callback_url" + assert captured["open_browser"] is False + assert captured["cancelled"] is True def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit( @@ -1481,7 +1571,7 @@ def test_openai_codex_oauth_login_reports_missing_oauth_cli_kit( real_import = builtins.__import__ def fake_import(name, *args, **kwargs): - if name == "oauth_cli_kit": + if name == "nanobot.providers.openai_codex_oauth": raise ImportError("missing") return real_import(name, *args, **kwargs) @@ -1542,6 +1632,7 @@ def test_xai_grok_login_starts_fresh_browser_flow_with_proxy( assert payload["status"] == "authorization_required" assert payload["provider"] == "xai_grok" assert payload["authorization_url"] == FakeFlow.authorization_url + assert payload["completion_input"] == "authorization_code" assert payload["flow_id"] callbacks: list[str | None] = [] diff --git a/tests/webui/test_settings_routes.py b/tests/webui/test_settings_routes.py index b0ad116b4..92dbec10f 100644 --- a/tests/webui/test_settings_routes.py +++ b/tests/webui/test_settings_routes.py @@ -27,15 +27,31 @@ def _router(*, authorized: bool = True) -> WebUISettingsRouter: ) +@pytest.mark.parametrize( + ("provider", "header_name", "authorization_response"), + [ + ("xai_grok", "X-Nanobot-OAuth-Code", "secret"), + ( + "openai_codex", + "X-Nanobot-OAuth-Callback", + "http://localhost:1455/auth/callback?code=secret&state=test", + ), + ], +) @pytest.mark.asyncio -async def test_xai_oauth_completion_reads_code_from_private_header(monkeypatch) -> None: +async def test_oauth_completion_reads_private_response_header( + monkeypatch, + provider: str, + header_name: str, + authorization_response: str, +) -> None: captured: dict[str, object] = {} - def complete(query, authorization_code=None): - captured.update(query=query, authorization_code=authorization_code) + def complete(query, authorization_response=None): + captured.update(query=query, authorization_response=authorization_response) return { "status": "pending", - "provider": "xai_grok", + "provider": provider, "flow_id": "flow-123", } @@ -44,13 +60,13 @@ async def test_xai_oauth_completion_reads_code_from_private_header(monkeypatch) request = SimpleNamespace( path=( "/api/settings/provider/oauth-login/complete" - "?provider=xai_grok&flow_id=flow-123" + f"?provider={provider}&flow_id=flow-123" ), headers=Headers( [ ( - "X-Nanobot-OAuth-Code", - "secret", + header_name, + authorization_response, ) ] ), @@ -66,14 +82,14 @@ async def test_xai_oauth_completion_reads_code_from_private_header(monkeypatch) assert response.status_code == 200 assert json.loads(response.body) == { "status": "pending", - "provider": "xai_grok", + "provider": provider, "flow_id": "flow-123", } assert captured == { - "query": {"provider": ["xai_grok"], "flow_id": ["flow-123"]}, - "authorization_code": "secret", + "query": {"provider": [provider], "flow_id": ["flow-123"]}, + "authorization_response": authorization_response, } - assert "secret" not in request.path + assert authorization_response not in request.path @pytest.mark.parametrize( diff --git a/webui/src/components/settings/SettingsView.tsx b/webui/src/components/settings/SettingsView.tsx index 6e9b06b80..0b27f36c7 100644 --- a/webui/src/components/settings/SettingsView.tsx +++ b/webui/src/components/settings/SettingsView.tsx @@ -661,11 +661,12 @@ export function SettingsView({ const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState(null); const [mcpPresetAction, setMcpPresetAction] = useState(null); const [providerSaving, setProviderSaving] = useState(null); - const [xaiOAuthFlow, setXaiOAuthFlow] = + const [providerOAuthFlow, setProviderOAuthFlow] = useState(null); - const xaiOAuthFlowRef = useRef(null); - const [xaiOAuthCode, setXaiOAuthCode] = useState(""); - const [xaiOAuthCompleting, setXaiOAuthCompleting] = useState(false); + const providerOAuthFlowRef = useRef(null); + const [providerOAuthResponse, setProviderOAuthResponse] = useState(""); + const [providerOAuthCompleting, setProviderOAuthCompleting] = useState(false); + const [providerOAuthDialogError, setProviderOAuthDialogError] = useState(null); const [webSearchSaving, setWebSearchSaving] = useState(false); const [imageGenerationSaving, setImageGenerationSaving] = useState(false); const [transcriptionSaving, setTranscriptionSaving] = useState(false); @@ -765,37 +766,44 @@ export function SettingsView({ [onSettingsChange], ); - const closeXaiOAuthFlow = useCallback(() => { - xaiOAuthFlowRef.current = null; - setXaiOAuthFlow(null); - setXaiOAuthCode(""); - setXaiOAuthCompleting(false); + const closeProviderOAuthFlow = useCallback(() => { + providerOAuthFlowRef.current = null; + setProviderOAuthFlow(null); + setProviderOAuthResponse(""); + setProviderOAuthCompleting(false); + setProviderOAuthDialogError(null); }, []); useEffect(() => { - if (!xaiOAuthFlow) return; + if (!providerOAuthFlow) return; let cancelled = false; let timer: number | null = null; const poll = async () => { try { const payload = await completeProviderOAuth( getToken(), - xaiOAuthFlow.provider, - xaiOAuthFlow.flow_id, + providerOAuthFlow.provider, + providerOAuthFlow.flow_id, ); - if (cancelled || xaiOAuthFlowRef.current?.flow_id !== xaiOAuthFlow.flow_id) return; + if ( + cancelled + || providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id + ) return; if (isProviderOAuthPending(payload)) { timer = window.setTimeout(() => void poll(), 1000); return; } applyPayload(payload); - setExpandedProvider(xaiOAuthFlow.provider); + setExpandedProvider(providerOAuthFlow.provider); setError(null); - closeXaiOAuthFlow(); + closeProviderOAuthFlow(); } catch (err) { - if (cancelled || xaiOAuthFlowRef.current?.flow_id !== xaiOAuthFlow.flow_id) return; + if ( + cancelled + || providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id + ) return; setError((err as Error).message); - closeXaiOAuthFlow(); + closeProviderOAuthFlow(); } }; timer = window.setTimeout(() => void poll(), 1000); @@ -803,7 +811,7 @@ export function SettingsView({ cancelled = true; if (timer !== null) window.clearTimeout(timer); }; - }, [applyPayload, closeXaiOAuthFlow, getToken, xaiOAuthFlow]); + }, [applyPayload, closeProviderOAuthFlow, getToken, providerOAuthFlow]); useEffect(() => { if (!initialSettings || settings !== null) return; @@ -1612,7 +1620,11 @@ export function SettingsView({ const runProviderOAuth = async (providerName: string, action: "login" | "logout") => { if (providerSaving) return; let popup: Window | null = null; - if (action === "login" && providerName === "xai_grok" && !remoteBrowserAccess) { + if ( + action === "login" + && providerName === "xai_grok" + && !remoteBrowserAccess + ) { try { popup = window.open("about:blank", "_blank"); if (popup) popup.opener = null; @@ -1624,7 +1636,12 @@ export function SettingsView({ try { const payload = action === "login" - ? await loginProviderOAuth(token, providerName) + ? await loginProviderOAuth( + token, + providerName, + "", + providerName === "openai_codex" && remoteBrowserAccess, + ) : await logoutProviderOAuth(token, providerName); if (isProviderOAuthAuthorizationRequired(payload)) { try { @@ -1632,15 +1649,16 @@ export function SettingsView({ } catch { // The dialog keeps the authorization link available when the popup was closed. } - xaiOAuthFlowRef.current = payload; - setXaiOAuthFlow(payload); - setXaiOAuthCode(""); + providerOAuthFlowRef.current = payload; + setProviderOAuthFlow(payload); + setProviderOAuthResponse(""); + setProviderOAuthDialogError(null); setExpandedProvider(providerName); setError(null); return; } popup?.close(); - closeXaiOAuthFlow(); + closeProviderOAuthFlow(); applyPayload(payload); setExpandedProvider(providerName); setError(null); @@ -1652,31 +1670,31 @@ export function SettingsView({ } }; - const completeXaiOAuth = async () => { - const flow = xaiOAuthFlowRef.current; - const authorizationCode = xaiOAuthCode.trim(); - if (!flow || !authorizationCode || xaiOAuthCompleting) return; - setXaiOAuthCompleting(true); + const completeProviderOAuthResponse = async () => { + const flow = providerOAuthFlowRef.current; + const authorizationResponse = providerOAuthResponse.trim(); + if (!flow || !authorizationResponse || providerOAuthCompleting) return; + setProviderOAuthCompleting(true); + setProviderOAuthDialogError(null); try { const payload = await completeProviderOAuth( token, flow.provider, flow.flow_id, - authorizationCode, + authorizationResponse, ); - if (xaiOAuthFlowRef.current?.flow_id !== flow.flow_id) return; + if (providerOAuthFlowRef.current?.flow_id !== flow.flow_id) return; if (isProviderOAuthPending(payload)) return; applyPayload(payload); setExpandedProvider(flow.provider); setError(null); - closeXaiOAuthFlow(); + closeProviderOAuthFlow(); } catch (err) { - if (xaiOAuthFlowRef.current?.flow_id === flow.flow_id) { - setError((err as Error).message); - closeXaiOAuthFlow(); + if (providerOAuthFlowRef.current?.flow_id === flow.flow_id) { + setProviderOAuthDialogError((err as Error).message); } } finally { - setXaiOAuthCompleting(false); + setProviderOAuthCompleting(false); } }; @@ -2317,19 +2335,33 @@ export function SettingsView({ onConfirm={handleDeleteModelConfiguration} /> - provider.name === providerOAuthFlow.provider) + ?.label ?? providerOAuthFlow.provider + : "" + } + authorizationResponse={providerOAuthResponse} + completing={providerOAuthCompleting} + error={providerOAuthDialogError} remoteBrowserAccess={remoteBrowserAccess} - onAuthorizationCodeChange={setXaiOAuthCode} + onAuthorizationResponseChange={(value) => { + setProviderOAuthResponse(value); + setProviderOAuthDialogError(null); + }} onOpenAuthorization={() => { - if (!xaiOAuthFlow) return; - const opened = window.open(xaiOAuthFlow.authorization_url, "_blank", "noopener,noreferrer"); + if (!providerOAuthFlow) return; + const opened = window.open( + providerOAuthFlow.authorization_url, + "_blank", + "noopener,noreferrer", + ); if (opened) opened.opener = null; }} - onComplete={() => void completeXaiOAuth()} - onClose={closeXaiOAuthFlow} + onComplete={() => void completeProviderOAuthResponse()} + onClose={closeProviderOAuthFlow} /> void; + onAuthorizationResponseChange: (value: string) => void; onOpenAuthorization: () => void; onComplete: () => void; onClose: () => void; }) { const { t } = useTranslation(); + const expectsCallbackUrl = flow?.completion_input === "callback_url"; + const inputId = expectsCallbackUrl ? "provider-oauth-callback" : "provider-oauth-code"; + const inputLabel = expectsCallbackUrl + ? t("settings.oauth.callbackUrl") + : t("settings.oauth.authorizationCode"); return ( - xAI Grok + {providerLabel} - {remoteBrowserAccess - ? t("settings.oauth.remoteCodeHelp") - : t("settings.oauth.localCodeHelp")} + {expectsCallbackUrl + ? remoteBrowserAccess + ? t("settings.oauth.remoteCallbackHelp") + : t("settings.oauth.localCallbackHelp") + : remoteBrowserAccess + ? t("settings.oauth.remoteCodeHelp") + : t("settings.oauth.localCodeHelp")} +
+ {expectsCallbackUrl && remoteBrowserAccess ? ( + + ) : ( + + )} + + {expectsCallbackUrl && remoteBrowserAccess + ? t("settings.oauth.pasteCallbackToContinue") + : t("settings.oauth.waitingForCallback")} + +
- onAuthorizationCodeChange(event.target.value)} - placeholder={t("settings.oauth.authorizationCode")} - aria-label={t("settings.oauth.authorizationCode")} - autoComplete="off" - spellCheck={false} - /> + {expectsCallbackUrl ? ( +