From 2a1f840ce2512bfc756f54744ada7f92c5f3f0fb Mon Sep 17 00:00:00 2001 From: chengyongru Date: Wed, 15 Jul 2026 11:05:55 +0800 Subject: [PATCH] fix(cli): support Codex OAuth in quick start --- nanobot/cli/onboard.py | 107 ++++++++++++++++++++++++--- tests/agent/test_onboard_logic.py | 118 +++++++++++++++++++++++++++++- 2 files changed, 213 insertions(+), 12 deletions(-) diff --git a/nanobot/cli/onboard.py b/nanobot/cli/onboard.py index abc6c53e..1185f851 100644 --- a/nanobot/cli/onboard.py +++ b/nanobot/cli/onboard.py @@ -3,6 +3,7 @@ import asyncio import json import types +from contextlib import suppress from dataclasses import dataclass from functools import lru_cache from typing import Any, Literal, NamedTuple, get_args, get_origin @@ -22,7 +23,7 @@ from nanobot.cli.models import ( get_model_context_limit, get_model_suggestions, ) -from nanobot.config.loader import get_config_path, load_config +from nanobot.config.loader import get_config_path, load_config, resolve_config_env_vars from nanobot.config.schema import Config, ModelPresetConfig console = Console() @@ -44,6 +45,8 @@ class _QuickStartProviderInfo(NamedTuple): default_api_base: str backend: str is_direct: bool + is_oauth: bool + default_model: str class _QuickStartEndpointChoice(NamedTuple): @@ -73,6 +76,7 @@ _BACK_PRESSED = object() # Sentinel value for back navigation _MODEL_PRESET_CACHE: set[str] = set() _QUICK_START_CUSTOM_PROVIDER_CHOICE = "Other OpenAI-compatible" +_QUICK_START_OAUTH_PROVIDERS = {"openai_codex"} _CLEAR_CHOICE = "Clear value" _QUICK_START_MENU_CHOICE = "[Q] Quick Start" @@ -1576,7 +1580,11 @@ def _get_quick_start_provider_info() -> dict[str, _QuickStartProviderInfo]: result: dict[str, _QuickStartProviderInfo] = {} for spec in PROVIDERS: - if spec.name == "custom" or spec.is_oauth or spec.is_transcription_only: + if ( + spec.name == "custom" + or spec.is_transcription_only + or (spec.is_oauth and spec.name not in _QUICK_START_OAUTH_PROVIDERS) + ): continue result[spec.name] = _QuickStartProviderInfo( display_name=spec.display_name or spec.name, @@ -1584,6 +1592,8 @@ def _get_quick_start_provider_info() -> dict[str, _QuickStartProviderInfo]: default_api_base=spec.default_api_base, backend=spec.backend, is_direct=spec.is_direct, + is_oauth=spec.is_oauth, + default_model=spec.builtin_models[0].id if spec.builtin_models else "", ) return result @@ -1599,7 +1609,64 @@ def _get_quick_start_provider_choices() -> dict[str, str]: def _quick_start_requires_api_key(provider_name: str, info: _QuickStartProviderInfo | None) -> bool: """Return whether Quick Start should ask for an API key.""" - return provider_name == "custom" or not (info and info.is_local) + return provider_name == "custom" or not (info and (info.is_local or info.is_oauth)) + + +def _quick_start_oauth_login(config: Config, provider_name: str) -> bool: + """Authenticate an OAuth provider supported by Quick Start.""" + if provider_name != "openai_codex": + console.print(f"[red]OAuth login is not supported for {provider_name}[/red]") + return False + + try: + from oauth_cli_kit import get_token, login_oauth_interactive + except ImportError: + console.print("[red]oauth_cli_kit not installed. Run: pip install oauth-cli-kit[/red]") + return False + + try: + proxy = resolve_config_env_vars(config).providers.openai_codex.proxy or None + except ValueError as exc: + console.print(f"[red]{exc}[/red]") + return False + + token = None + with suppress(Exception): + token = get_token(proxy=proxy) + if not (token and token.access): + console.print("[cyan]Starting interactive OAuth login...[/cyan]\n") + try: + token = login_oauth_interactive( + print_fn=lambda message: console.print(message), + prompt_fn=lambda prompt: _get_questionary().text(prompt).ask() or "", + proxy=proxy, + ) + except Exception as exc: + console.print(f"[red]OAuth login failed: {exc}[/red]") + return False + + if not (token and token.access): + console.print("[red]OAuth login failed[/red]") + return False + + account = getattr(token, "account_id", None) + suffix = f" [dim]{account}[/dim]" if account else "" + console.print(f"[green]Authenticated with OpenAI Codex[/green]{suffix}") + return True + + +def _quick_start_oauth_is_authenticated(config: Config, provider_name: str) -> bool: + """Return whether Quick Start can load a usable OAuth token.""" + if provider_name != "openai_codex": + return False + try: + from oauth_cli_kit import get_token + + proxy = resolve_config_env_vars(config).providers.openai_codex.proxy or None + token = get_token(proxy=proxy) + except Exception: + return False + return bool(token and token.access) def _quick_start_requires_base_url(provider_name: str, info: _QuickStartProviderInfo | None) -> bool: @@ -1710,7 +1777,11 @@ def _configure_quick_start_provider(config: Config) -> bool | object: console.print(f"[red]Unknown provider: {provider_name}[/red]") return False - model = _input_model_with_autocomplete("Model ID", "", provider_name) + model = _input_model_with_autocomplete( + "Model ID", + provider_info.default_model if provider_info else "", + provider_name, + ) if model is _BACK_PRESSED: continue model = (model or "").strip() @@ -1718,6 +1789,10 @@ def _configure_quick_start_provider(config: Config) -> bool | object: console.print("[yellow]! Model ID is required for Quick Start[/yellow]") return False + if provider_info and provider_info.is_oauth: + if not _quick_start_oauth_login(config, provider_name): + return False + if api_key is not None: provider_config.api_key = api_key if api_base: @@ -1784,17 +1859,27 @@ def _show_quick_start_summary(config: Config) -> None: _show_quick_start_progress(3) preset = config.model_presets.get("primary") provider_label = "AI provider" - has_api_key = True + credentials_ready = True + credential_name = "API key" if preset: provider_config = getattr(config.providers, preset.provider, None) - provider_label, _is_gateway, is_local, _api_base = _get_provider_info().get( - preset.provider, (preset.provider, False, False, "") - ) - has_api_key = is_local or bool(provider_config and provider_config.api_key) + provider_info = _get_quick_start_provider_info().get(preset.provider) + if provider_info: + provider_label = provider_info.display_name + if provider_info.is_oauth: + credential_name = "OAuth login" + credentials_ready = _quick_start_oauth_is_authenticated(config, preset.provider) + else: + credentials_ready = provider_info.is_local or bool( + provider_config and provider_config.api_key + ) + else: + provider_label = _get_provider_names().get(preset.provider, preset.provider) + credentials_ready = bool(provider_config and provider_config.api_key) status = "Ready" - if not has_api_key: - status = f"{provider_label} API key missing" + if not credentials_ready: + status = f"{provider_label} {credential_name} missing" rows = [ ("Status", status), diff --git a/tests/agent/test_onboard_logic.py b/tests/agent/test_onboard_logic.py index 633b34e3..e1174188 100644 --- a/tests/agent/test_onboard_logic.py +++ b/tests/agent/test_onboard_logic.py @@ -978,7 +978,14 @@ class TestMainMenuUpdate: expected_provider_names = set() seen_display_names: set[str] = set() for spec in PROVIDERS: - if spec.name == "custom" or spec.is_oauth or spec.is_transcription_only: + if ( + spec.name == "custom" + or spec.is_transcription_only + or ( + spec.is_oauth + and spec.name not in onboard_wizard._QUICK_START_OAUTH_PROVIDERS + ) + ): continue if spec.display_name in seen_display_names: continue @@ -988,9 +995,118 @@ class TestMainMenuUpdate: assert selected_provider_names == expected_provider_names assert "assemblyai" not in selected_provider_names + assert choices["OpenAI Codex"] == "openai_codex" + assert "github_copilot" not in selected_provider_names assert choices["OpenCode Zen"] == "opencode" assert choices[onboard_wizard._QUICK_START_CUSTOM_PROVIDER_CHOICE] == "custom" + def test_quick_start_openai_codex_uses_oauth_and_default_model(self, monkeypatch): + """Codex should authenticate without asking for an API key.""" + config = Config() + oauth_calls: list[tuple[Config, str]] = [] + model_prompts: list[tuple[str, str, str]] = [] + + monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) + monkeypatch.setattr( + onboard_wizard, + "_select_with_back", + lambda *args, **kwargs: "OpenAI Codex", + ) + + def fail_api_key_prompt(*_args, **_kwargs): + raise AssertionError("OpenAI Codex Quick Start should not ask for an API key") + + def fake_model_input(prompt, current, provider): + model_prompts.append((prompt, current, provider)) + return current + + monkeypatch.setattr(onboard_wizard, "_input_text", fail_api_key_prompt) + monkeypatch.setattr(onboard_wizard, "_input_model_with_autocomplete", fake_model_input) + monkeypatch.setattr( + onboard_wizard, + "_quick_start_oauth_login", + lambda selected_config, provider: oauth_calls.append( + (selected_config, provider) + ) + or True, + ) + + assert onboard_wizard._configure_quick_start_provider(config) is True + + assert oauth_calls == [(config, "openai_codex")] + assert model_prompts == [ + ("Model ID", "openai-codex/gpt-5.6-sol", "openai_codex") + ] + assert config.providers.openai_codex.api_key is None + assert config.model_presets["primary"].provider == "openai_codex" + assert config.model_presets["primary"].model == "openai-codex/gpt-5.6-sol" + + def test_quick_start_openai_codex_login_failure_does_not_create_preset(self, monkeypatch): + """A failed Codex login must not leave a ready-looking model preset.""" + config = Config() + + monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) + monkeypatch.setattr( + onboard_wizard, + "_select_with_back", + lambda *args, **kwargs: "OpenAI Codex", + ) + monkeypatch.setattr( + onboard_wizard, + "_input_model_with_autocomplete", + lambda *args, **kwargs: "openai-codex/gpt-5.6-sol", + ) + monkeypatch.setattr(onboard_wizard, "_quick_start_oauth_login", lambda *args: False) + + assert onboard_wizard._configure_quick_start_provider(config) is False + assert "primary" not in config.model_presets + + def test_quick_start_openai_codex_login_reuses_existing_token(self, monkeypatch): + """Quick Start should not open a new login flow when Codex is already authenticated.""" + import oauth_cli_kit + + config = Config() + token = SimpleNamespace(access="existing-token", account_id="account-123") + login_calls: list[object] = [] + + monkeypatch.setattr(oauth_cli_kit, "get_token", lambda **kwargs: token) + monkeypatch.setattr( + oauth_cli_kit, + "login_oauth_interactive", + lambda **kwargs: login_calls.append(kwargs), + ) + monkeypatch.setattr(onboard_wizard.console, "print", lambda *args, **kwargs: None) + + assert onboard_wizard._quick_start_oauth_login(config, "openai_codex") is True + assert login_calls == [] + + def test_quick_start_summary_reports_missing_codex_oauth(self, monkeypatch): + """The review step should distinguish OAuth from an API-key setup.""" + config = Config() + config.model_presets["primary"] = ModelPresetConfig( + model="openai-codex/gpt-5.6-sol", + provider="openai_codex", + ) + captured: dict[str, list[tuple[str, str]]] = {} + + monkeypatch.setattr(onboard_wizard, "_show_quick_start_progress", lambda *_args: None) + monkeypatch.setattr( + onboard_wizard, + "_quick_start_oauth_is_authenticated", + lambda *args: False, + ) + monkeypatch.setattr( + onboard_wizard, + "_print_summary_panel", + lambda rows, _title: captured.setdefault("rows", rows), + ) + + onboard_wizard._show_quick_start_summary(config) + + rows = dict(captured["rows"]) + assert rows["Status"] == "OpenAI Codex OAuth login missing" + assert rows["WebSocket channel"] == "enabled" + def test_quick_start_provider_choice_skips_advanced_prompts(self, monkeypatch): """The beginner path should ask for provider credentials and model.""" config = Config()