diff --git a/raven/cli/agent_commands.py b/raven/cli/agent_commands.py index 6f863ee..96e0b30 100644 --- a/raven/cli/agent_commands.py +++ b/raven/cli/agent_commands.py @@ -16,6 +16,7 @@ import asyncio import signal import sys +from pathlib import Path import typer from prompt_toolkit import PromptSession @@ -220,10 +221,17 @@ def agent( # fail loudly later rather than block on prompts. from raven.cli.onboard_commands import _is_config_populated - if message is None and _stdout_isatty() and not _is_config_populated(): + startup_config_path = Path(config).expanduser().resolve() if config else None + config_target_exists = startup_config_path is None or startup_config_path.exists() + if ( + message is None + and _stdout_isatty() + and config_target_exists + and not _is_config_populated(startup_config_path) + ): from raven.cli.onboard_commands import ensure_configured_or_onboard - ensure_configured_or_onboard() + ensure_configured_or_onboard(config_path=startup_config_path) from loguru import logger @@ -240,10 +248,9 @@ def agent( from raven.proactive_engine.schedulers.cron.service import CronService from raven.session.manager import SessionManager, new_chat_id - # load_runtime_config must run FIRST: it calls set_config_path() so - # that subsequent load_raven_config() reads from --config, not the - # default ~/.raven/config.json. Otherwise skill_forge / sentinel - # from --config are silently ignored. + # The startup gate above reads the same explicit path without changing + # global loader state. load_runtime_config now sets that state before + # all remaining config consumers run. config = load_runtime_config(config, workspace) ec_config = load_raven_config() sentinel_cfg = ec_config.sentinel diff --git a/raven/cli/onboard_commands.py b/raven/cli/onboard_commands.py index f75330c..81688cb 100644 --- a/raven/cli/onboard_commands.py +++ b/raven/cli/onboard_commands.py @@ -30,6 +30,7 @@ from __future__ import annotations import sys +from pathlib import Path from typing import Any, Callable, Optional import typer @@ -326,7 +327,7 @@ def _check_tty_or_die(non_interactive: bool) -> None: raise typer.Exit(2) -def _load_raw_config() -> dict[str, Any]: +def _load_raw_config(config_path: Path | None = None) -> dict[str, Any]: """Return the parsed on-disk config, or ``{}`` if absent/empty. A present-but-unparseable config raises ConfigReadError (surfaced cleanly by @@ -336,7 +337,7 @@ def _load_raw_config() -> dict[str, Any]: """ from raven.config.loader import get_config_path, read_raw_or_raise - return read_raw_or_raise(get_config_path()) or {} + return read_raw_or_raise(config_path or get_config_path()) or {} def _configured_providers() -> list[str]: @@ -346,22 +347,37 @@ def _configured_providers() -> list[str]: return [row["name"] for row in list_providers() if row["configured"]] -def _is_config_populated() -> bool: - """True iff at least one provider has a key AND a default model is set. +def _is_config_populated(config_path: Path | None = None) -> bool: + """True iff the selected provider is configured and a default model is set. "Populated" for the startup gate means the required step (Step 1) is - satisfied: a provider key plus ``agents.defaults.model``. Either alone is - not enough to talk to a model. + satisfied: provider credentials plus ``agents.defaults.model``. OAuth + providers keep their credentials outside ``config.json``, so use the + provider ops layer's shared configuration check. Credentials for an + unrelated provider must not satisfy the gate. """ - from raven.providers.registry import split_model_id + from raven.config.schema import Config + from raven.config.update_providers import list_providers - data = _load_raw_config() + data = _load_raw_config(config_path) model = (data.get("agents", {}) or {}).get("defaults", {}).get("model") - configured = _configured_providers() - model_prefix, _ = split_model_id(str(model or "")) - has_non_minimax_provider = any(name not in {"minimax_global", "minimax_cn"} for name in configured) - has_provider = has_non_minimax_provider or model_prefix in configured - return bool(has_provider and model) + if not model: + return False + routing_config = Config.model_validate( + { + "agents": data.get("agents") or {}, + "providers": data.get("providers") or {}, + } + ) + selected_provider = routing_config.get_provider_name(model) + provider_status = { + provider["name"]: provider["configured"] + for provider in list_providers( + config_path=config_path, + raw_providers=data.get("providers") or {}, + ) + } + return bool(selected_provider and provider_status.get(selected_provider, False)) def _handle_existing_config(*, reset: bool, yes: bool, non_interactive: bool) -> None: @@ -4683,7 +4699,11 @@ def _run_wizard_body( # --------------------------------------------------------------------------- -def ensure_configured_or_onboard(*, non_interactive: bool = False) -> bool: +def ensure_configured_or_onboard( + *, + non_interactive: bool = False, + config_path: Path | None = None, +) -> bool: """Run the wizard when the required config (provider + model) is missing. Returns ``True`` if config was already complete (caller proceeds straight @@ -4691,8 +4711,12 @@ def ensure_configured_or_onboard(*, non_interactive: bool = False) -> bool: a non-interactive context with missing config, the wizard's TTY check will raise — callers on non-TTY paths must guard before invoking. """ - if _is_config_populated(): + if _is_config_populated(config_path): return True + if config_path is not None: + from raven.config.loader import set_config_path + + set_config_path(config_path) run_wizard(non_interactive=non_interactive) return False diff --git a/raven/config/update_providers.py b/raven/config/update_providers.py index 7d0408a..8c7e29f 100644 --- a/raven/config/update_providers.py +++ b/raven/config/update_providers.py @@ -5,13 +5,12 @@ functions defined here. Direct ``load_config`` / ``save_config`` on the providers section is forbidden -- see plan rule. -OAuth providers have a separate -auth path via ``provider_commands._LOGIN_HANDLERS`` and store tokens via -``oauth_cli_kit``, not in ``config.json``. ``set_provider_fields`` refuses -to write ``api_key`` for those providers; callers must invoke -``provider login`` for that. ``reset_provider`` handles both cases: -schema-default rewrite for config fields, plus unlinking the -``oauth_cli_kit`` token file when the provider has ``is_oauth=True``. +OAuth providers have separate auth paths via +``provider_commands._LOGIN_HANDLERS`` and keep credentials outside +``config.json``. ``set_provider_fields`` refuses to write ``api_key`` for +those providers; callers must invoke ``provider login`` for that. +``reset_provider`` rewrites the schema defaults and removes the provider's +external credentials. """ from __future__ import annotations @@ -429,14 +428,102 @@ def _oauth_token_path(provider_name: str) -> Path: so tests can point at ``tmp_path`` without touching real user data. """ override = os.environ.get("OAUTH_CLI_KIT_TOKEN_PATH") - if override: + if provider_name == "openai_codex" and override: return Path(override) + filename = "codex.json" if provider_name == "openai_codex" else f"{provider_name}.json" + if provider_name == "openai_codex": + try: + from oauth_cli_kit import OPENAI_CODEX_PROVIDER + + filename = OPENAI_CODEX_PROVIDER.token_filename + except (ImportError, AttributeError): + pass try: from platformdirs import user_data_dir except ImportError: - return Path.home() / ".local" / "share" / "oauth-cli-kit" / "auth" / f"{provider_name}.json" + return Path.home() / ".local" / "share" / "oauth-cli-kit" / "auth" / filename base_dir = Path(user_data_dir("oauth-cli-kit", appauthor=False)) - return base_dir / "auth" / f"{provider_name}.json" + return base_dir / "auth" / filename + + +def _github_copilot_token_is_usable() -> bool: + """Match LiteLLM 1.85's offline GitHub Copilot credential semantics. + + An unexpired cached API key is immediately usable. A stored GitHub access + token is refresh-capable, so the normal request path can exchange it for an + API key. The environment variable names and default files mirror + ``litellm.llms.github_copilot.authenticator.Authenticator`` without + instantiating it (its constructor creates directories). + """ + import time + + token_dir = Path( + os.environ.get( + "GITHUB_COPILOT_TOKEN_DIR", + str(Path.home() / ".config" / "litellm" / "github_copilot"), + ) + ) + access_path = token_dir / os.environ.get("GITHUB_COPILOT_ACCESS_TOKEN_FILE", "access-token") + api_key_path = token_dir / os.environ.get("GITHUB_COPILOT_API_KEY_FILE", "api-key.json") + try: + if access_path.is_file() and access_path.read_text(encoding="utf-8").strip(): + return True + if not api_key_path.is_file(): + return False + payload = json.loads(api_key_path.read_text(encoding="utf-8")) + token = payload.get("token") if isinstance(payload, dict) else None + expires_at = payload.get("expires_at") if isinstance(payload, dict) else None + if ( + not isinstance(token, str) + or not token.strip() + or isinstance(expires_at, bool) + or not isinstance(expires_at, (int, float)) + ): + return False + return expires_at > time.time() + except (OSError, UnicodeError, json.JSONDecodeError, TypeError, ValueError): + return False + + +def _oauth_token_is_usable(provider_name: str) -> bool: + """Return whether the provider's stored OAuth credentials are usable. + + This check never refreshes credentials or makes a network request. Codex + loading delegates to oauth-cli-kit's storage contract, including its + cache-miss import from ``~/.codex/auth.json``. An expired Codex access + token remains usable when it has a refresh token because the real request + path refreshes it; ``account_id`` is mandatory because Raven sends it as a + request header. + """ + if provider_name == "github_copilot": + return _github_copilot_token_is_usable() + if provider_name != "openai_codex": + return False + try: + from oauth_cli_kit import OPENAI_CODEX_PROVIDER + from oauth_cli_kit.storage import FileTokenStorage + + token = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename).load() + except Exception: + return False + if token is None: + return False + access = token.access + refresh = token.refresh + account_id = token.account_id + expires = token.expires + if not isinstance(access, str) or not access.strip(): + return False + if not isinstance(refresh, str) or not refresh.strip(): + return False + if not isinstance(account_id, str) or not account_id.strip(): + return False + if isinstance(expires, bool): + return False + try: + return int(expires) > 0 + except (TypeError, ValueError): + return False # --------------------------------------------------------------------------- @@ -461,7 +548,11 @@ def provider_field_specs(name: str) -> dict[str, dict[str, Any]]: # --------------------------------------------------------------------------- -def list_providers(*, config_path: Path | None = None) -> list[dict[str, Any]]: +def list_providers( + *, + config_path: Path | None = None, + raw_providers: dict[str, Any] | None = None, +) -> list[dict[str, Any]]: """Reflect every provider declared on ``ProvidersConfig`` + current status. Returns one dict per provider: @@ -474,8 +565,12 @@ def list_providers(*, config_path: Path | None = None) -> list[dict[str, Any]]: - ``api_key_redacted`` ``****set****`` / ``(empty)`` / ``(not needed for local)`` - ``api_base`` current value (or ``None`` if untouched) """ - path = config_path or get_config_path() - data = read_raw_or_raise(path) + if raw_providers is None: + path = config_path or get_config_path() + data = read_raw_or_raise(path) + raw_providers = data.get("providers") or {} + else: + data = {"providers": raw_providers} out: list[dict[str, Any]] = [] for fname in _listable_provider_names(data): @@ -515,7 +610,7 @@ def list_providers(*, config_path: Path | None = None) -> list[dict[str, Any]]: configured = load_token("global" if fname == "minimax_global" else "cn") is not None else: - configured = _oauth_token_path(fname).exists() + configured = _oauth_token_is_usable(fname) api_key_redacted = "OAuth token" if configured else "(empty)" elif is_local: configured = bool(api_base) or bool(api_key) diff --git a/tests/test_cli_onboard_commands.py b/tests/test_cli_onboard_commands.py index 2f4f357..d2b7c6e 100644 --- a/tests/test_cli_onboard_commands.py +++ b/tests/test_cli_onboard_commands.py @@ -92,6 +92,19 @@ def tmp_env(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: """ cfg = tmp_path / "config.json" workspace = tmp_path / "workspace" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv( + "OAUTH_CLI_KIT_TOKEN_PATH", + str(tmp_path / "oauth-cli-kit" / "codex.json"), + ) + monkeypatch.setenv( + "GITHUB_COPILOT_TOKEN_DIR", + str(tmp_path / "github-copilot"), + ) + monkeypatch.setenv( + "MINIMAX_OAUTH_TOKEN_DIR", + str(tmp_path / "minimax-oauth"), + ) set_config_path(cfg) monkeypatch.setattr( "raven.config.paths.get_workspace_path", @@ -857,6 +870,20 @@ def _seed_provider(provider: str = "openai", key: str = "sk-seed", model: str = set_default_model(model) +def _seed_oauth_provider(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Write a default OAuth model and a representative external token.""" + from raven.config.update import set_default_model + + token_file = tmp_path / "openai_codex.json" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_file)) + set_default_model("openai_codex/gpt-5") + token_file.write_text( + '{"access":"test-token","refresh":"test-refresh","expires":4102444800000,"account_id":"test-account"}', + encoding="utf-8", + ) + + # --------------------------------------------------------------------------- gate @@ -899,6 +926,167 @@ def test_is_config_populated_accepts_minimax_oauth_token( assert onboard_commands._is_config_populated() is True +def test_is_config_populated_accepts_oauth_provider( + tmp_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An OAuth token outside config.json satisfies the provider gate.""" + token_file = tmp_path / "openai_codex.json" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_file)) + from raven.config.update import set_default_model + + set_default_model("openai_codex/gpt-5") + assert onboard_commands._is_config_populated() is False + _seed_oauth_provider(tmp_path, monkeypatch) + assert onboard_commands._is_config_populated() is True + + +def test_is_config_populated_rejects_unrelated_oauth_credentials( + tmp_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A Codex token cannot satisfy a model routed to Anthropic.""" + from raven.config.update import set_default_model + + _seed_oauth_provider(tmp_path, monkeypatch) + set_default_model("anthropic/claude-sonnet-4-5") + + assert onboard_commands._is_config_populated() is False + + +def test_is_config_populated_rejects_oauth_token_without_account_id( + tmp_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from raven.config.update import set_default_model + + token_file = tmp_path / "codex.json" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_file)) + set_default_model("openai_codex/gpt-5") + token_file.write_text( + '{"access":"token","refresh":"refresh","expires":4102444800000}', + encoding="utf-8", + ) + + assert onboard_commands._is_config_populated() is False + + +def test_is_config_populated_imports_valid_codex_cli_credentials( + tmp_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """The gate uses oauth-cli-kit's cache-miss import from ~/.codex.""" + from raven.config.update import set_default_model + + token_file = tmp_path / "oauth-cache" / "codex.json" + codex_auth = tmp_path / ".codex" / "auth.json" + codex_auth.parent.mkdir() + codex_auth.write_text( + json.dumps( + { + "tokens": { + "access_token": "legacy-access", + "refresh_token": "legacy-refresh", + "account_id": "legacy-account", + } + } + ), + encoding="utf-8", + ) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_file)) + set_default_model("openai_codex/gpt-5") + + assert onboard_commands._is_config_populated() is True + assert json.loads(token_file.read_text())["account_id"] == "legacy-account" + + +def test_is_config_populated_accepts_expired_refreshable_codex_token( + tmp_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Expired access is accepted when the real request path can refresh it.""" + from raven.config.update import set_default_model + + token_file = tmp_path / "codex.json" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_file)) + set_default_model("openai_codex/gpt-5") + token_file.write_text( + '{"access":"token","refresh":"refresh","expires":1,"account_id":"account"}', + encoding="utf-8", + ) + + assert onboard_commands._is_config_populated() is True + + +def test_is_config_populated_accepts_github_copilot_access_token( + tmp_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from raven.config.update import set_default_model + + token_dir = tmp_path / "copilot" + token_dir.mkdir() + (token_dir / "access-token").write_text("github-access", encoding="utf-8") + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(token_dir)) + set_default_model("github_copilot/gpt-4o") + + assert onboard_commands._is_config_populated() is True + + +def test_is_config_populated_accepts_selected_api_key_provider(tmp_env: Path) -> None: + """An API key still satisfies the gate when its provider owns the model.""" + _seed_provider(provider="gemini", key="gemini-key", model="gemini/gemini-2.5-flash") + + assert onboard_commands._is_config_populated() is True + + +def test_is_config_populated_uses_single_config_snapshot( + tmp_env: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """An atomic replace cannot mix provider routing from two config reads.""" + snapshot = { + "agents": {"defaults": {"model": "openai/gpt-4o-mini"}}, + "providers": {"openai": {"apiKey": "snapshot-key"}}, + } + reads: list[Path] = [] + + def _read_once(path: Path): + reads.append(path) + if len(reads) > 1: + raise AssertionError("startup gate read the config more than once") + return snapshot + + monkeypatch.setattr("raven.config.loader.read_raw_or_raise", _read_once) + monkeypatch.setattr("raven.config.update_providers.read_raw_or_raise", _read_once) + + assert onboard_commands._is_config_populated(tmp_env) is True + assert reads == [tmp_env] + + +def test_is_config_populated_rejects_forced_unconfigured_provider(tmp_env: Path) -> None: + """A forced provider must have its own usable credentials.""" + from raven.config.update_providers import set_provider_fields + + set_provider_fields("openai", {"api_key": "sk-openai"}) + data = json.loads(tmp_env.read_text()) + data.setdefault("agents", {}).setdefault("defaults", {}) + data["agents"]["defaults"].update({"model": "openai/gpt-4o-mini", "provider": "anthropic"}) + tmp_env.write_text(json.dumps(data), encoding="utf-8") + + assert onboard_commands._is_config_populated() is False + + def test_ensure_configured_short_circuits_when_complete(tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: """The gate returns True (no wizard) when config is already complete.""" _seed_provider() @@ -960,6 +1148,101 @@ def _boom(*a, **kw): assert gate_called == [] +def test_agent_gate_skips_when_oauth_populated( + tmp_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`raven agent` does not onboard an authenticated OAuth-only setup.""" + from raven.cli import agent_commands + + _seed_oauth_provider(tmp_path, monkeypatch) + monkeypatch.setattr(agent_commands, "_stdout_isatty", lambda: True) + gate_called: list[bool] = [] + monkeypatch.setattr( + onboard_commands, + "ensure_configured_or_onboard", + lambda **_: gate_called.append(True), + ) + monkeypatch.setattr( + "raven.cli._helpers.load_runtime_config", + lambda *a, **kw: (_ for _ in ()).throw(typer.Exit(0)), + ) + + runner.invoke(app, ["agent"]) + + assert gate_called == [] + + +def test_agent_gate_uses_valid_oauth_custom_config( + tmp_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`agent --config` gates against the same file the runtime will load.""" + from raven.cli import agent_commands + from raven.config.update import set_default_model + + custom_config = tmp_path / "custom-valid.json" + token_file = tmp_path / "codex.json" + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_file)) + set_default_model("openai_codex/gpt-5", config_path=custom_config) + token_file.write_text( + '{"access":"token","refresh":"refresh","expires":4102444800000,"account_id":"account"}', + encoding="utf-8", + ) + monkeypatch.setattr(agent_commands, "_stdout_isatty", lambda: True) + gate_called: list[bool] = [] + monkeypatch.setattr( + onboard_commands, + "ensure_configured_or_onboard", + lambda **_: gate_called.append(True), + ) + runtime_paths: list[str | None] = [] + + def _runtime(config, workspace): + runtime_paths.append(config) + raise typer.Exit(0) + + monkeypatch.setattr(agent_commands, "load_runtime_config", _runtime) + + result = runner.invoke(app, ["agent", "--config", str(custom_config)]) + + assert result.exit_code == 0 + assert gate_called == [] + assert runtime_paths == [str(custom_config)] + + +def test_agent_gate_rejects_invalid_custom_config_despite_valid_default( + tmp_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A valid default config cannot bypass an invalid `--config` target.""" + from raven.cli import agent_commands + + _seed_provider() + custom_config = tmp_path / "custom-invalid.json" + custom_config.write_text( + json.dumps({"agents": {"defaults": {"model": "anthropic/claude-sonnet-4-5"}}}), + encoding="utf-8", + ) + monkeypatch.setattr(agent_commands, "_stdout_isatty", lambda: True) + seen_paths: list[Path | None] = [] + + def _gate(**kwargs): + seen_paths.append(kwargs.get("config_path")) + raise typer.Exit(0) + + monkeypatch.setattr(onboard_commands, "ensure_configured_or_onboard", _gate) + + result = runner.invoke(app, ["agent", "--config", str(custom_config)]) + + assert result.exit_code == 0 + assert seen_paths == [custom_config.resolve()] + + def test_agent_gate_skips_oneshot_message(tmp_env: Path, monkeypatch: pytest.MonkeyPatch) -> None: """`raven agent -m '...'` (one-shot) must NOT enter the wizard even on a TTY with missing config — scripted use fails loudly later instead.""" @@ -1033,6 +1316,29 @@ def test_tui_gate_skips_check_flag(tmp_env: Path, monkeypatch: pytest.MonkeyPatc assert gate_called == [] +def test_tui_gate_skips_when_oauth_populated( + tmp_env: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """`raven tui` does not onboard an authenticated OAuth-only setup.""" + from raven.cli import tui_commands + + _seed_oauth_provider(tmp_path, monkeypatch) + monkeypatch.setattr(tui_commands, "_stdout_isatty", lambda: True) + gate_called: list[bool] = [] + monkeypatch.setattr( + onboard_commands, + "ensure_configured_or_onboard", + lambda **_: gate_called.append(True), + ) + monkeypatch.setattr(tui_commands, "find_node", lambda: (None, None)) + + runner.invoke(app, ["tui"]) + + assert gate_called == [] + + # --------------------------------------------------------------------------- sandbox step diff --git a/tests/test_config_update_providers.py b/tests/test_config_update_providers.py index 1cbc3c8..6424d19 100644 --- a/tests/test_config_update_providers.py +++ b/tests/test_config_update_providers.py @@ -11,6 +11,7 @@ import pytest from raven.config.update_providers import ( + _oauth_token_path, add_provider_model, get_provider_config, list_providers, @@ -248,6 +249,170 @@ def test_list_reports_every_provider_with_correct_status(cfg_path: Path) -> None assert len(rows) >= 18 +def test_list_uses_supplied_provider_snapshot_without_rereading( + cfg_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + def _unexpected_read(_path: Path): + raise AssertionError("provider snapshot must prevent a second config read") + + monkeypatch.setattr("raven.config.update_providers.read_raw_or_raise", _unexpected_read) + + rows = { + row["name"]: row + for row in list_providers( + config_path=cfg_path, + raw_providers={"openai": {"apiKey": "snapshot-key"}}, + ) + } + + assert rows["openai"]["configured"] is True + assert rows["anthropic"]["configured"] is False + + +@pytest.mark.parametrize( + "contents", + [ + "", + "not-json", + "{}", + '{"access":"token","expires":4102444800}', + '{"access":"token","refresh":"refresh","expires":true}', + '{"access":"token","refresh":"refresh","expires":"never"}', + ], +) +def test_list_rejects_unusable_oauth_token( + cfg_path: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + contents: str, +) -> None: + token_file = tmp_path / "codex.json" + token_file.write_text(contents, encoding="utf-8") + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_file)) + + rows = {row["name"]: row for row in list_providers(config_path=cfg_path)} + + assert rows["openai_codex"]["configured"] is False + assert "token" not in repr(rows["openai_codex"]).lower() + + +def test_list_accepts_structurally_usable_oauth_token( + cfg_path: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + token_file = tmp_path / "codex.json" + token_file.write_text( + '{"access":"secret-access","refresh":"secret-refresh","expires":4102444800000,"account_id":"secret-account"}', + encoding="utf-8", + ) + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_file)) + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(tmp_path / "copilot-missing")) + + rows = {row["name"]: row for row in list_providers(config_path=cfg_path)} + + assert rows["openai_codex"]["configured"] is True + assert rows["openai_codex"]["api_key_redacted"] == "OAuth token" + assert "secret-access" not in repr(rows) + assert "secret-refresh" not in repr(rows) + assert "secret-account" not in repr(rows) + assert rows["github_copilot"]["configured"] is False + + +def test_list_reports_github_copilot_access_token( + cfg_path: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + token_dir = tmp_path / "copilot" + token_dir.mkdir() + (token_dir / "access-token").write_text("github-access", encoding="utf-8") + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(token_dir)) + + rows = {row["name"]: row for row in list_providers(config_path=cfg_path)} + + assert rows["github_copilot"]["configured"] is True + assert "github-access" not in repr(rows) + + +def test_list_reports_unexpired_github_copilot_api_key( + cfg_path: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + token_dir = tmp_path / "copilot" + token_dir.mkdir() + (token_dir / "api-key.json").write_text( + '{"token":"copilot-api-key","expires_at":4102444800}', + encoding="utf-8", + ) + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(token_dir)) + + rows = {row["name"]: row for row in list_providers(config_path=cfg_path)} + + assert rows["github_copilot"]["configured"] is True + assert "copilot-api-key" not in repr(rows) + + +def test_list_rejects_expired_github_copilot_api_key( + cfg_path: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + token_dir = tmp_path / "copilot" + token_dir.mkdir() + (token_dir / "api-key.json").write_text( + '{"token":"expired-key","expires_at":1}', + encoding="utf-8", + ) + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(token_dir)) + + rows = {row["name"]: row for row in list_providers(config_path=cfg_path)} + + assert rows["github_copilot"]["configured"] is False + + +def test_list_rejects_string_expiry_github_copilot_api_key( + cfg_path: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """LiteLLM compares expires_at numerically and rejects JSON strings.""" + token_dir = tmp_path / "copilot" + token_dir.mkdir() + (token_dir / "api-key.json").write_text( + '{"token":"typed-wrong","expires_at":"4102444800"}', + encoding="utf-8", + ) + monkeypatch.setenv("GITHUB_COPILOT_TOKEN_DIR", str(token_dir)) + + rows = {row["name"]: row for row in list_providers(config_path=cfg_path)} + + assert rows["github_copilot"]["configured"] is False + + +def test_list_rejects_oauth_token_directory( + cfg_path: Path, + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + token_path = tmp_path / "codex.json" + token_path.mkdir() + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path)) + + rows = {row["name"]: row for row in list_providers(config_path=cfg_path)} + + assert rows["openai_codex"]["configured"] is False + + +def test_codex_default_token_filename_matches_oauth_cli_kit(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.delenv("OAUTH_CLI_KIT_TOKEN_PATH", raising=False) + + assert _oauth_token_path("openai_codex").name == "codex.json" + + # --------------------------------------------------------------------------- # provider_field_specs # --------------------------------------------------------------------------- diff --git a/tests/test_tui_rpc_model.py b/tests/test_tui_rpc_model.py index 88bfb5f..ac47d39 100644 --- a/tests/test_tui_rpc_model.py +++ b/tests/test_tui_rpc_model.py @@ -31,6 +31,15 @@ def fake_home(monkeypatch, tmp_path) -> Path: # Clear any process-wide config-path override a prior test left set, so # get_config_path() falls back to the patched Path.home (monkeypatch restores it). monkeypatch.setattr("raven.config.loader._current_config_path", None) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv( + "OAUTH_CLI_KIT_TOKEN_PATH", + str(tmp_path / "oauth-cli-kit" / "codex.json"), + ) + monkeypatch.setenv( + "GITHUB_COPILOT_TOKEN_DIR", + str(tmp_path / "litellm" / "github_copilot"), + ) return tmp_path