From 3ceabdecd5f2da029e148ee65132654552d345be Mon Sep 17 00:00:00 2001 From: chengyongru <2755839590@qq.com> Date: Mon, 4 May 2026 00:26:22 +0800 Subject: [PATCH] feat(cli): support github-copilot in provider logout Logout previously claimed to support github-copilot in --help text but had no registered handler, so `provider logout github-copilot` failed with "Logout not implemented". Add the handler, sharing token deletion with the codex flow via `_delete_oauth_files`. Tighten handler-table types, fix the codex test fixture filename, and cover github-copilot plus the unknown provider path. --- nanobot/cli/commands.py | 54 +++++++++++++---- nanobot/providers/github_copilot_provider.py | 6 +- tests/cli/test_commands.py | 61 +++++++++++++++++++- 3 files changed, 105 insertions(+), 16 deletions(-) diff --git a/nanobot/cli/commands.py b/nanobot/cli/commands.py index abf4d5c9b..a062802a9 100644 --- a/nanobot/cli/commands.py +++ b/nanobot/cli/commands.py @@ -5,6 +5,7 @@ import os import select import signal import sys +from collections.abc import Callable from contextlib import nullcontext, suppress from pathlib import Path from typing import Any @@ -1487,8 +1488,13 @@ provider_app = typer.Typer(help="Manage providers") app.add_typer(provider_app, name="provider") -_LOGIN_HANDLERS: dict[str, Any] = {} -_LOGOUT_HANDLERS: dict[str, Any] = {} +_LOGIN_HANDLERS: dict[str, Callable[[], None]] = {} +_LOGOUT_HANDLERS: dict[str, Callable[[], None]] = {} + +_PROVIDER_DISPLAY: dict[str, str] = { + "openai_codex": "OpenAI Codex", + "github_copilot": "GitHub Copilot", +} def _register_login(name: str): @@ -1587,20 +1593,46 @@ def _logout_openai_codex() -> None: raise typer.Exit(1) storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename) + _delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["openai_codex"]) + + +@_register_logout("github_copilot") +def _logout_github_copilot() -> None: + """Clear local OAuth credentials for GitHub Copilot.""" + try: + from nanobot.providers.github_copilot_provider import get_storage + except ImportError: + console.print("[red]GitHub Copilot provider unavailable. Ensure oauth-cli-kit is installed.[/red]") + raise typer.Exit(1) + + storage = get_storage() + _delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["github_copilot"]) + + +def _delete_oauth_files(token_path: Path, provider_label: str) -> None: + """Delete OAuth token and lock files, reporting the result.""" removed_paths: list[Path] = [] - - for path in (storage.get_token_path(), storage.get_token_path().with_suffix(".lock")): - if path.exists(): + skipped: list[tuple[Path, OSError]] = [] + for path in (token_path, token_path.with_suffix(".lock")): + try: path.unlink() - removed_paths.append(path) + except FileNotFoundError: + continue + except OSError as exc: + skipped.append((path, exc)) + continue + removed_paths.append(path) - if not removed_paths: - console.print("[yellow]! No local OAuth credentials found for OpenAI Codex[/yellow]") + if not removed_paths and not skipped: + console.print(f"[yellow]! No local OAuth credentials found for {provider_label}[/yellow]") return - console.print("[green]✓ Logged out from OpenAI Codex[/green]") - for path in removed_paths: - console.print(f"[dim]Removed: {path}[/dim]") + if removed_paths: + console.print(f"[green]✓ Logged out from {provider_label}[/green]") + for path in removed_paths: + console.print(f"[dim]Removed: {path}[/dim]") + for path, exc in skipped: + console.print(f"[yellow]! Could not remove {path}: {exc}[/yellow]") @_register_login("github_copilot") diff --git a/nanobot/providers/github_copilot_provider.py b/nanobot/providers/github_copilot_provider.py index dbc49e73e..acd5d0574 100644 --- a/nanobot/providers/github_copilot_provider.py +++ b/nanobot/providers/github_copilot_provider.py @@ -29,7 +29,7 @@ _EXPIRY_SKEW_SECONDS = 60 _LONG_LIVED_TOKEN_SECONDS = 315360000 -def _storage() -> FileTokenStorage: +def get_storage() -> FileTokenStorage: return FileTokenStorage( token_filename=TOKEN_FILENAME, app_name=TOKEN_APP_NAME, @@ -48,7 +48,7 @@ def _copilot_headers(token: str) -> dict[str, str]: def _load_github_token() -> OAuthToken | None: - token = _storage().load() + token = get_storage().load() if not token or not token.access: return None return token @@ -150,7 +150,7 @@ def login_github_copilot( expires=expires_ms, account_id=str(account_id) if account_id else None, ) - _storage().save(token) + get_storage().save(token) return token diff --git a/tests/cli/test_commands.py b/tests/cli/test_commands.py index ce2fe6b0e..d217c5f03 100644 --- a/tests/cli/test_commands.py +++ b/tests/cli/test_commands.py @@ -221,7 +221,7 @@ def test_config_dump_excludes_oauth_provider_blocks(): def test_provider_logout_openai_codex_removes_local_oauth_files(tmp_path, monkeypatch): - token_path = tmp_path / "auth" / "oauth.json" + token_path = tmp_path / "auth" / "codex.json" lock_path = token_path.with_suffix(".lock") token_path.parent.mkdir(parents=True, exist_ok=True) token_path.write_text("{}", encoding="utf-8") @@ -237,7 +237,7 @@ def test_provider_logout_openai_codex_removes_local_oauth_files(tmp_path, monkey def test_provider_logout_openai_codex_succeeds_when_no_local_oauth_file(monkeypatch, tmp_path): - token_path = tmp_path / "auth" / "oauth.json" + token_path = tmp_path / "auth" / "codex.json" monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path)) result = runner.invoke(app, ["provider", "logout", "openai-codex"]) @@ -246,6 +246,63 @@ def test_provider_logout_openai_codex_succeeds_when_no_local_oauth_file(monkeypa assert "No local OAuth credentials found for OpenAI Codex" in result.stdout +def test_provider_logout_github_copilot_removes_local_oauth_files(tmp_path, monkeypatch): + token_path = tmp_path / "auth" / "github-copilot.json" + lock_path = token_path.with_suffix(".lock") + token_path.parent.mkdir(parents=True, exist_ok=True) + token_path.write_text("{}", encoding="utf-8") + lock_path.write_text("", encoding="utf-8") + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path)) + + result = runner.invoke(app, ["provider", "logout", "github-copilot"]) + + assert result.exit_code == 0 + assert not token_path.exists() + assert not lock_path.exists() + assert "Logged out from GitHub Copilot" in result.stdout + + +def test_provider_logout_github_copilot_succeeds_when_no_local_oauth_file(monkeypatch, tmp_path): + token_path = tmp_path / "auth" / "github-copilot.json" + monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path)) + + result = runner.invoke(app, ["provider", "logout", "github-copilot"]) + + assert result.exit_code == 0 + assert "No local OAuth credentials found for GitHub Copilot" in result.stdout + + +def test_provider_logout_rejects_unknown_provider(): + result = runner.invoke(app, ["provider", "logout", "not-a-real-provider"]) + + assert result.exit_code == 1 + assert "Unknown OAuth provider" in result.stdout + + +def test_provider_logout_paths_resolve_to_expected_files(): + from oauth_cli_kit.providers import OPENAI_CODEX_PROVIDER + from oauth_cli_kit.storage import FileTokenStorage + + from nanobot.providers.github_copilot_provider import get_storage + + codex_storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename) + codex_path = codex_storage.get_token_path() + assert codex_path.name == "codex.json" + assert codex_path.parent.name == "auth" + + gh_storage = get_storage() + gh_path = gh_storage.get_token_path() + assert gh_path.name == "github-copilot.json" + assert gh_path.parent.name == "auth" + + +def test_provider_login_rejects_unknown_provider(): + result = runner.invoke(app, ["provider", "login", "not-a-real-provider"]) + + assert result.exit_code == 1 + assert "Unknown OAuth provider" in result.stdout + + def test_config_matches_explicit_ollama_prefix_without_api_key(): config = Config() config.agents.defaults.model = "ollama/llama3.2"