Files
hermes-agent/tests/hermes_cli/test_copilot_auth.py
Test 21c45ba0ac feat: proper Copilot auth with OAuth device code flow and token validation
Builds on PR #1879's Copilot integration with critical auth improvements
modeled after opencode's implementation:

- Add hermes_cli/copilot_auth.py with:
  - OAuth device code flow (copilot_device_code_login) using the same
    client_id (Ov23li8tweQw6odWQebz) as opencode and Copilot CLI
  - Token type validation: reject classic PATs (ghp_*) with a clear
    error message explaining supported token types
  - Proper env var priority: COPILOT_GITHUB_TOKEN > GH_TOKEN > GITHUB_TOKEN
    (matching Copilot CLI documentation)
  - copilot_request_headers() with Openai-Intent, x-initiator, and
    Copilot-Vision-Request headers (matching opencode)

- Update auth.py:
  - PROVIDER_REGISTRY copilot entry uses correct env var order
  - _resolve_api_key_provider_secret delegates to copilot_auth for
    the copilot provider with proper token validation

- Update models.py:
  - copilot_default_headers() now includes Openai-Intent and x-initiator

- Update main.py:
  - _model_flow_copilot offers OAuth device code login when no token
    is found, with manual token entry as fallback
  - Shows supported vs unsupported token types

- 22 new tests covering token validation, env var priority, header
  generation, and integration with existing auth infrastructure
2026-03-18 03:25:58 -07:00

179 lines
7.5 KiB
Python

"""Tests for hermes_cli.copilot_auth — Copilot token validation and resolution."""
import os
import pytest
from unittest.mock import patch, MagicMock
class TestTokenValidation:
"""Token type validation."""
def test_classic_pat_rejected(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("ghp_abcdefghijklmnop1234")
assert valid is False
assert "Classic Personal Access Tokens" in msg
assert "ghp_" in msg
def test_oauth_token_accepted(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("gho_abcdefghijklmnop1234")
assert valid is True
def test_fine_grained_pat_accepted(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("github_pat_abcdefghijklmnop1234")
assert valid is True
def test_github_app_token_accepted(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("ghu_abcdefghijklmnop1234")
assert valid is True
def test_empty_token_rejected(self):
from hermes_cli.copilot_auth import validate_copilot_token
valid, msg = validate_copilot_token("")
assert valid is False
def test_is_classic_pat(self):
from hermes_cli.copilot_auth import is_classic_pat
assert is_classic_pat("ghp_abc123") is True
assert is_classic_pat("gho_abc123") is False
assert is_classic_pat("github_pat_abc") is False
assert is_classic_pat("") is False
class TestResolveToken:
"""Token resolution with env var priority."""
def test_copilot_github_token_first_priority(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "gho_copilot_first")
monkeypatch.setenv("GH_TOKEN", "gho_gh_second")
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
token, source = resolve_copilot_token()
assert token == "gho_copilot_first"
assert source == "COPILOT_GITHUB_TOKEN"
def test_gh_token_second_priority(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.setenv("GH_TOKEN", "gho_gh_second")
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
token, source = resolve_copilot_token()
assert token == "gho_gh_second"
assert source == "GH_TOKEN"
def test_github_token_third_priority(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", "gho_github_third")
token, source = resolve_copilot_token()
assert token == "gho_github_third"
assert source == "GITHUB_TOKEN"
def test_classic_pat_in_env_skipped(self, monkeypatch):
"""Classic PATs in env vars should be skipped, not returned."""
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.setenv("COPILOT_GITHUB_TOKEN", "ghp_classic_pat_nope")
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.setenv("GITHUB_TOKEN", "gho_valid_oauth")
token, source = resolve_copilot_token()
# Should skip the ghp_ token and find the gho_ one
assert token == "gho_valid_oauth"
assert source == "GITHUB_TOKEN"
def test_gh_cli_fallback(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="gho_from_cli"):
token, source = resolve_copilot_token()
assert token == "gho_from_cli"
assert source == "gh auth token"
def test_gh_cli_classic_pat_raises(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value="ghp_classic"):
with pytest.raises(ValueError, match="classic PAT"):
resolve_copilot_token()
def test_no_token_returns_empty(self, monkeypatch):
from hermes_cli.copilot_auth import resolve_copilot_token
monkeypatch.delenv("COPILOT_GITHUB_TOKEN", raising=False)
monkeypatch.delenv("GH_TOKEN", raising=False)
monkeypatch.delenv("GITHUB_TOKEN", raising=False)
with patch("hermes_cli.copilot_auth._try_gh_cli_token", return_value=None):
token, source = resolve_copilot_token()
assert token == ""
assert source == ""
class TestRequestHeaders:
"""Copilot API header generation."""
def test_default_headers_include_openai_intent(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers()
assert headers["Openai-Intent"] == "conversation-edits"
assert headers["User-Agent"] == "HermesAgent/1.0"
assert "Editor-Version" in headers
def test_agent_turn_sets_initiator(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers(is_agent_turn=True)
assert headers["x-initiator"] == "agent"
def test_user_turn_sets_initiator(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers(is_agent_turn=False)
assert headers["x-initiator"] == "user"
def test_vision_header(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers(is_vision=True)
assert headers["Copilot-Vision-Request"] == "true"
def test_no_vision_header_by_default(self):
from hermes_cli.copilot_auth import copilot_request_headers
headers = copilot_request_headers()
assert "Copilot-Vision-Request" not in headers
class TestCopilotDefaultHeaders:
"""The models.py copilot_default_headers uses copilot_auth."""
def test_includes_openai_intent(self):
from hermes_cli.models import copilot_default_headers
headers = copilot_default_headers()
assert "Openai-Intent" in headers
assert headers["Openai-Intent"] == "conversation-edits"
def test_includes_x_initiator(self):
from hermes_cli.models import copilot_default_headers
headers = copilot_default_headers()
assert "x-initiator" in headers
class TestEnvVarOrder:
"""PROVIDER_REGISTRY has correct env var order."""
def test_copilot_env_vars_include_copilot_github_token(self):
from hermes_cli.auth import PROVIDER_REGISTRY
copilot = PROVIDER_REGISTRY["copilot"]
assert "COPILOT_GITHUB_TOKEN" in copilot.api_key_env_vars
# COPILOT_GITHUB_TOKEN should be first
assert copilot.api_key_env_vars[0] == "COPILOT_GITHUB_TOKEN"
def test_copilot_env_vars_order_matches_docs(self):
from hermes_cli.auth import PROVIDER_REGISTRY
copilot = PROVIDER_REGISTRY["copilot"]
assert copilot.api_key_env_vars == (
"COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN"
)