diff --git a/hermes_cli/copilot_auth.py b/hermes_cli/copilot_auth.py index d520b1310b..ce7c0dd117 100644 --- a/hermes_cli/copilot_auth.py +++ b/hermes_cli/copilot_auth.py @@ -25,7 +25,10 @@ logger = logging.getLogger(__name__) # VS Code's GitHub App client ID: mints ghu_* tokens exchangeable for Copilot API JWTs (needed for # internal-only models / enterprise endpoints). The opencode App ID mints gho_* tokens that 404. COPILOT_OAUTH_CLIENT_ID = "Iv1.b507a08c87ecfe98" -_CLASSIC_PAT_PREFIX = "ghp_" # rejected by the Copilot API (gho_ / github_pat_ / ghu_ work) +_CLASSIC_PAT_PREFIX = "ghp_" # rejected by the Copilot API +# Token families the Copilot API exchanges. Anything else (a random string in GITHUB_TOKEN) used +# to pass validation and then fail downstream with an opaque auth error (#12650). +_SUPPORTED_PREFIXES = ("gho_", "github_pat_", "ghu_") COPILOT_ENV_VARS = ("COPILOT_GITHUB_TOKEN", "GH_TOKEN", "GITHUB_TOKEN") _DEVICE_CODE_POLL_INTERVAL = 5 # seconds _DEVICE_CODE_POLL_SAFETY_MARGIN = 3 # seconds @@ -43,6 +46,10 @@ def validate_copilot_token(token: str) -> tuple[bool, str]: " → `copilot login` or `hermes model` to authenticate via OAuth\n" " → A fine-grained PAT (github_pat_*) with Copilot Requests permission\n" " → `gh auth login` with the default device code flow (produces gho_* tokens)") + if not token.startswith(_SUPPORTED_PREFIXES): + return False, ( + "Unsupported GitHub token format for the Copilot API. " + f"Supported token prefixes: {', '.join(_SUPPORTED_PREFIXES)}.") return True, "OK" @@ -73,7 +80,7 @@ def resolve_copilot_token() -> tuple[str, str]: if token: valid, msg = validate_copilot_token(token) if not valid: - raise ValueError(f"Token from `gh auth token` is a classic PAT (ghp_*). {msg}") + raise ValueError(f"Token from `gh auth token` is not usable with Copilot. {msg}") return token, "gh auth token" return "", "" diff --git a/tests/hermes_cli/test_copilot_auth.py b/tests/hermes_cli/test_copilot_auth.py index 334f1eb2e4..a8c20ac6ec 100644 --- a/tests/hermes_cli/test_copilot_auth.py +++ b/tests/hermes_cli/test_copilot_auth.py @@ -14,6 +14,18 @@ class TestTokenValidation: assert "Classic Personal Access Tokens" in msg assert "ghp_" in msg + @pytest.mark.parametrize("token", ["gho_abcdefghijklmnop1234", "github_pat_abcdefghijklmnop1234", "ghu_abcdefghijklmnop1234"]) + def test_supported_token_families_accepted(self, token): + from hermes_cli.copilot_auth import validate_copilot_token + assert validate_copilot_token(token) == (True, "OK") + + def test_arbitrary_string_rejected(self): + """A non-GitHub value in GITHUB_TOKEN must fail validation instead of reaching the API (#12650).""" + from hermes_cli.copilot_auth import validate_copilot_token + valid, msg = validate_copilot_token("not_a_github_token") + assert valid is False + assert "Supported token prefixes" in msg + class TestResolveToken: """Token resolution with env var priority.""" @@ -37,7 +49,7 @@ class TestResolveToken: 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"): + with pytest.raises(ValueError, match="Classic Personal Access Tokens"): resolve_copilot_token() def test_invalid_env_var_skips_gh_cli_fallback(self, monkeypatch):