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.
This commit is contained in:
chengyongru 2026-05-04 00:26:22 +08:00 committed by Xubin Ren
parent 807b8188e3
commit 3ceabdecd5
3 changed files with 105 additions and 16 deletions

View File

@ -5,6 +5,7 @@ import os
import select import select
import signal import signal
import sys import sys
from collections.abc import Callable
from contextlib import nullcontext, suppress from contextlib import nullcontext, suppress
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@ -1487,8 +1488,13 @@ provider_app = typer.Typer(help="Manage providers")
app.add_typer(provider_app, name="provider") app.add_typer(provider_app, name="provider")
_LOGIN_HANDLERS: dict[str, Any] = {} _LOGIN_HANDLERS: dict[str, Callable[[], None]] = {}
_LOGOUT_HANDLERS: dict[str, Any] = {} _LOGOUT_HANDLERS: dict[str, Callable[[], None]] = {}
_PROVIDER_DISPLAY: dict[str, str] = {
"openai_codex": "OpenAI Codex",
"github_copilot": "GitHub Copilot",
}
def _register_login(name: str): def _register_login(name: str):
@ -1587,20 +1593,46 @@ def _logout_openai_codex() -> None:
raise typer.Exit(1) raise typer.Exit(1)
storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename) storage = FileTokenStorage(token_filename=OPENAI_CODEX_PROVIDER.token_filename)
removed_paths: list[Path] = [] _delete_oauth_files(storage.get_token_path(), _PROVIDER_DISPLAY["openai_codex"])
for path in (storage.get_token_path(), storage.get_token_path().with_suffix(".lock")):
if path.exists(): @_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] = []
skipped: list[tuple[Path, OSError]] = []
for path in (token_path, token_path.with_suffix(".lock")):
try:
path.unlink() path.unlink()
except FileNotFoundError:
continue
except OSError as exc:
skipped.append((path, exc))
continue
removed_paths.append(path) removed_paths.append(path)
if not removed_paths: if not removed_paths and not skipped:
console.print("[yellow]! No local OAuth credentials found for OpenAI Codex[/yellow]") console.print(f"[yellow]! No local OAuth credentials found for {provider_label}[/yellow]")
return return
console.print("[green]✓ Logged out from OpenAI Codex[/green]") if removed_paths:
console.print(f"[green]✓ Logged out from {provider_label}[/green]")
for path in removed_paths: for path in removed_paths:
console.print(f"[dim]Removed: {path}[/dim]") 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") @_register_login("github_copilot")

View File

@ -29,7 +29,7 @@ _EXPIRY_SKEW_SECONDS = 60
_LONG_LIVED_TOKEN_SECONDS = 315360000 _LONG_LIVED_TOKEN_SECONDS = 315360000
def _storage() -> FileTokenStorage: def get_storage() -> FileTokenStorage:
return FileTokenStorage( return FileTokenStorage(
token_filename=TOKEN_FILENAME, token_filename=TOKEN_FILENAME,
app_name=TOKEN_APP_NAME, app_name=TOKEN_APP_NAME,
@ -48,7 +48,7 @@ def _copilot_headers(token: str) -> dict[str, str]:
def _load_github_token() -> OAuthToken | None: def _load_github_token() -> OAuthToken | None:
token = _storage().load() token = get_storage().load()
if not token or not token.access: if not token or not token.access:
return None return None
return token return token
@ -150,7 +150,7 @@ def login_github_copilot(
expires=expires_ms, expires=expires_ms,
account_id=str(account_id) if account_id else None, account_id=str(account_id) if account_id else None,
) )
_storage().save(token) get_storage().save(token)
return token return token

View File

@ -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): 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") lock_path = token_path.with_suffix(".lock")
token_path.parent.mkdir(parents=True, exist_ok=True) token_path.parent.mkdir(parents=True, exist_ok=True)
token_path.write_text("{}", encoding="utf-8") 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): 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)) monkeypatch.setenv("OAUTH_CLI_KIT_TOKEN_PATH", str(token_path))
result = runner.invoke(app, ["provider", "logout", "openai-codex"]) 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 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(): def test_config_matches_explicit_ollama_prefix_without_api_key():
config = Config() config = Config()
config.agents.defaults.model = "ollama/llama3.2" config.agents.defaults.model = "ollama/llama3.2"