diff --git a/CHANGELOG.md b/CHANGELOG.md index f8d31f27b..6eab6355c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Virtual subdirectory and raw-file packages now resolve from self-hosted Git services (Gitea, Gogs) via raw URL with API v1/v3 fallback. (#587) - `shared/apm.md` gh-aw shared workflow exposes a `target:` import input (default `all`) so consumer workflows can ship slim, single-harness bundles instead of always packing every layout. (#1184) +- If you use the `gh` CLI, APM is now zero-config for private GitHub packages on github.com, `*.ghe.com`, and GHES: APM uses your active `gh auth login` token (`gh auth token --hostname `) before falling back to `git credential fill`. Silently skipped when `gh` is not installed or not logged in for the host. (#630) ### Fixed diff --git a/docs/src/content/docs/getting-started/authentication.md b/docs/src/content/docs/getting-started/authentication.md index 79c8cb14a..810f0eff8 100644 --- a/docs/src/content/docs/getting-started/authentication.md +++ b/docs/src/content/docs/getting-started/authentication.md @@ -12,9 +12,12 @@ APM resolves tokens per `(host, org)` pair. For each dependency, it walks a reso 1. **Per-org env var** — `GITHUB_APM_PAT_{ORG}` (GitHub-like hosts — not ADO) 2. **Global env vars** — `GITHUB_APM_PAT` → `GITHUB_TOKEN` → `GH_TOKEN` (any host) -3. **Git credential helper** — `git credential fill` (any host except ADO) +3. **GitHub CLI active account** — `gh auth token --hostname ` (GitHub-like hosts; silently skipped if `gh` is not installed or not logged in) +4. **Git credential helper** — `git credential fill` (any host except ADO) -If the global token doesn't work for the target host, APM automatically retries with git credential helpers. If nothing matches, APM attempts unauthenticated access (works for public repos on github.com). +Steps 1 and 2 cover the four token-priority rows in the table below (priorities 1-4). The numbering above collapses the three global env vars (`GITHUB_APM_PAT`, `GITHUB_TOKEN`, `GH_TOKEN`) into a single resolution step. + +If the global token doesn't work for the target host, APM next tries the active `gh` CLI account before falling back to git credential helpers. If nothing matches, APM attempts unauthenticated access (works for public repos on github.com). Results are cached per-process — the same `(host, org)` pair is resolved once. @@ -30,7 +33,8 @@ All token-bearing requests use HTTPS. Tokens are never sent over unencrypted con | 2 | `GITHUB_APM_PAT` | Any host | Falls back to git credential helpers if rejected | | 3 | `GITHUB_TOKEN` | Any host | Shared with GitHub Actions | | 4 | `GH_TOKEN` | Any host | Set by `gh auth login` | -| 5 | `git credential fill` | Per-host | System credential manager, `gh auth`, OS keychain | +| 5 | `gh auth token --hostname ` | GitHub-like hosts | Active `gh auth login` account | +| 6 | `git credential fill` | Per-host | System credential manager, `gh auth`, OS keychain | For Azure DevOps, APM resolves credentials in this order: `ADO_APM_PAT` env var, then a Microsoft Entra ID (AAD) bearer token from the Azure CLI (`az`). See [Azure DevOps](#azure-devops) below. @@ -213,10 +217,10 @@ When authentication fails, APM prints a targeted diagnostic instead of a generic | Package source | Host | Auth behavior | Fallback | |---|---|---|---| -| `org/repo` (bare) | `default_host()` | Global env vars → credential fill | Unauth for public repos | -| `github.com/org/repo` | github.com | Global env vars → credential fill | Unauth for public repos | -| `contoso.ghe.com/org/repo` | *.ghe.com | Global env vars → credential fill | Auth-only (no public repos) | -| GHES via `GITHUB_HOST` | ghes.company.com | Global env vars → credential fill | Unauth for public repos | +| `org/repo` (bare) | `default_host()` | Global env vars → `gh auth token` → credential fill | Unauth for public repos | +| `github.com/org/repo` | github.com | Global env vars → `gh auth token` → credential fill | Unauth for public repos | +| `contoso.ghe.com/org/repo` | *.ghe.com | Global env vars → `gh auth token` → credential fill | Auth-only (no public repos) | +| GHES via `GITHUB_HOST` | ghes.company.com | Global env vars → `gh auth token` → credential fill | Unauth for public repos | | `dev.azure.com/org/proj/repo` | ADO | `ADO_APM_PAT` -> AAD bearer via `az` | Auth-only | | Artifactory registry proxy | custom FQDN | `PROXY_REGISTRY_TOKEN` | Error if `PROXY_REGISTRY_ONLY=1` | @@ -312,21 +316,24 @@ flowchart TD B -->|GITHUB_APM_PAT_ORG| C[Use per-org token] B -->|Not set| D{Global env var?} D -->|GITHUB_APM_PAT / GITHUB_TOKEN / GH_TOKEN| E[Use global token] - D -->|Not set| F{Git credential fill?} - F -->|Found| G[Use credential] - F -->|Not found| H[No token] + D -->|Not set| F{gh auth token?
GitHub-like hosts only} + F -->|Found| G[Use gh token] + F -->|Not found| H{Git credential fill?} + H -->|Found| J[Use credential] + H -->|Not found| K[No token] E --> I{try_with_fallback} C --> I G --> I - H --> I - - I -->|Token works| J[Success] - I -->|Token fails| K{Credential-fill fallback} - K -->|Found credential| J - K -->|No credential| L{Host has public repos?} - L -->|Yes| M[Try unauthenticated] - L -->|No| N[Auth error with actionable message] + J --> I + K --> I + + I -->|Token works| L[Success] + I -->|Token fails| M{Fallback credentials} + M -->|gh or git credential found| L + M -->|No credential| N{Host has public repos?} + N -->|Yes| O[Try unauthenticated] + N -->|No| P[Auth error with actionable message] ``` ### Git credential helper not found diff --git a/docs/src/content/docs/getting-started/quick-start.md b/docs/src/content/docs/getting-started/quick-start.md index 4e1010ac3..664ad5921 100644 --- a/docs/src/content/docs/getting-started/quick-start.md +++ b/docs/src/content/docs/getting-started/quick-start.md @@ -61,6 +61,10 @@ This is where it gets interesting. Install a package and watch what happens: apm install microsoft/apm-sample-package#v1.0.0 ``` +:::tip[Already use the gh CLI?] +If you are logged in with `gh auth login`, APM is already authenticated for private GitHub packages on github.com, `*.ghe.com`, and GHES -- no env vars to set. +::: + APM downloads the package, resolves its dependencies, and deploys files directly into the directories your AI tools already watch: ``` diff --git a/packages/apm-guide/.apm/skills/apm-usage/authentication.md b/packages/apm-guide/.apm/skills/apm-usage/authentication.md index b957e396c..d69777dab 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/authentication.md +++ b/packages/apm-guide/.apm/skills/apm-usage/authentication.md @@ -10,9 +10,12 @@ APM checks these sources in order, using the first valid token found: | 2 | `GITHUB_APM_PAT` | Global | Falls back to git credential if rejected | | 3 | `GITHUB_TOKEN` | Global | Shared with GitHub Actions | | 4 | `GH_TOKEN` | Global | Set by `gh auth login` | -| 5 | `git credential fill` | Per-host | System credential manager | +| 5 | `gh auth token --hostname ` | GitHub-like hosts | Active `gh auth login` account | +| 6 | `git credential fill` | Per-host | System credential manager | | -- | None | -- | Unauthenticated (public GitHub repos only) | +APM checks the active `gh` CLI account before invoking OS credential helpers. This reduces ambiguous multi-account prompts on hosts like github.com. If the `gh` CLI is not installed or no account is active, APM skips this step silently and continues to `git credential fill`. + ## Per-org setup Use per-org tokens when accessing packages across multiple organizations: @@ -143,7 +146,7 @@ apm install --verbose owner/repo/path#v1.2.0 # Diagnose the auth chain -- shows which token source is used apm install --verbose your-org/package -# Increase git credential timeout (default 30s, max 180s) +# Increase git credential timeout (default 60s, max 180s) export APM_GIT_CREDENTIAL_TIMEOUT=120 ``` diff --git a/src/apm_cli/core/auth.py b/src/apm_cli/core/auth.py index 057d26b33..a2918a278 100644 --- a/src/apm_cli/core/auth.py +++ b/src/apm_cli/core/auth.py @@ -344,7 +344,8 @@ def try_with_fallback( When the resolved token comes from a global env var and fails (e.g. a github.com PAT tried on ``*.ghe.com``), the method - retries with ``git credential fill`` before giving up. + retries with ``gh auth token`` and then ``git credential fill`` + before giving up. """ auth_ctx = self.resolve(host, org, port=port) host_info = auth_ctx.host_info @@ -355,21 +356,41 @@ def _log(msg: str) -> None: verbose_callback(msg) def _try_credential_fallback(exc: Exception) -> T: - """Retry with git-credential-fill when an env-var token fails.""" - if auth_ctx.source in ("git-credential-fill", "none"): + """Retry the operation when the originally-resolved token fails. + + Walks the secondary chain in order: gh CLI (GitHub-like hosts; + internal guard short-circuits unsupported hosts), then + ``git credential fill``. Sources already obtained from a + secondary chain (``gh-auth-token``, ``git-credential-fill``, + ``none``) skip retry to avoid double-invocation. + """ + if auth_ctx.source in ("gh-auth-token", "git-credential-fill", "none"): raise exc # ADO uses ADO_APM_PAT + AAD bearer fallback; credential fill is out of scope. if host_info.kind == "ado": raise exc _log( - f"Token from {auth_ctx.source} failed, trying git credential fill " - f"for {host_info.display_name}" + f"Token from {auth_ctx.source} failed for {host_info.display_name}; " + "trying secondary credential sources" ) + _log(f"trying gh auth token for {host_info.display_name}") + gh_token = self._token_manager.resolve_credential_from_gh_cli(host_info.host) + if gh_token: + _log(f"gh auth token resolved a credential for {host_info.display_name}") + return operation( + gh_token, + self._build_git_env(gh_token, scheme="basic", host_kind=host_info.kind), + ) + _log(f"trying git credential fill for {host_info.display_name}") cred = self._token_manager.resolve_credential_from_git( host_info.host, port=host_info.port ) if cred: - return operation(cred, self._build_git_env(cred)) + _log(f"git credential fill resolved a credential for {host_info.display_name}") + return operation( + cred, + self._build_git_env(cred, scheme="basic", host_kind=host_info.kind), + ) raise exc # ADO bearer fallback machinery (PAT was tried first; bearer is the safety net) @@ -641,7 +662,8 @@ def _resolve_token(self, host_info: HostInfo, org: str | None) -> tuple[str | No 2. Global env vars ``GITHUB_APM_PAT`` -> ``GITHUB_TOKEN`` -> ``GH_TOKEN`` (any host -- if the token is wrong for the target host, ``try_with_fallback`` retries with git credentials) - 3. Git credential helper (any host except ADO) + 3. gh CLI active account (GitHub-like hosts only) + 4. Git credential helper (any host except ADO) Resolution order (ADO): 1. ``ADO_APM_PAT`` env var -> scheme ``"basic"`` @@ -688,7 +710,13 @@ def _resolve_token(self, host_info: HostInfo, org: str | None) -> tuple[str | No source = self._identify_env_source(purpose) return token, source, "basic" - # 3. Git credential helper (not for ADO) + # 3. gh CLI active account (eligibility gated inside the call; + # unsupported hosts return None instantly without a subprocess) + gh_token = self._token_manager.resolve_credential_from_gh_cli(host_info.host) + if gh_token: + return gh_token, "gh-auth-token", "basic" + + # 4. Git credential helper (not for ADO) if host_info.kind not in ("ado",): credential = self._token_manager.resolve_credential_from_git( host_info.host, port=host_info.port diff --git a/src/apm_cli/core/token_manager.py b/src/apm_cli/core/token_manager.py index 2d3616842..05af3043a 100644 --- a/src/apm_cli/core/token_manager.py +++ b/src/apm_cli/core/token_manager.py @@ -11,18 +11,28 @@ - GITHUB_TOKEN: User-scoped PAT for GitHub Models API access Platform Token Selection: -- GitHub: GITHUB_APM_PAT -> GITHUB_TOKEN -> GH_TOKEN -> git credential helpers +- GitHub: GITHUB_APM_PAT -> GITHUB_TOKEN -> GH_TOKEN -> gh auth token -> git credential helpers - Azure DevOps: ADO_APM_PAT Runtime Requirements: - Codex CLI: Uses GITHUB_TOKEN (must be user-scoped for GitHub Models) """ +import logging import os import subprocess import sys from typing import Dict, Optional, Tuple # noqa: F401, UP035 +from apm_cli.utils.github_host import ( + default_host, + is_azure_devops_hostname, + is_github_hostname, + is_valid_fqdn, +) + +logger = logging.getLogger(__name__) + def _format_credential_host(host: str, port: int | None) -> str: """Embed a custom port into the git credential ``host`` field. @@ -93,6 +103,24 @@ def _is_valid_credential_token(token: str) -> bool: return False return True + @staticmethod + def _supports_gh_cli_host(host: str | None) -> bool: + """Return True when *host* should use gh CLI fallback.""" + if not host: + return False + if is_github_hostname(host): + return True + + configured_host = default_host().lower() + host_lower = host.lower() + if host_lower != configured_host: + return False + if configured_host == "github.com" or configured_host.endswith(".ghe.com"): + return False + if is_azure_devops_hostname(configured_host): + return False + return is_valid_fqdn(configured_host) + # `git credential fill` may invoke OS credential helpers that show # interactive dialogs (e.g. Windows Credential Manager account picker). # The 60s default prevents false negatives on slow helpers. @@ -159,6 +187,52 @@ def resolve_credential_from_git(host: str, port: int | None = None) -> str | Non except (subprocess.TimeoutExpired, FileNotFoundError, OSError): return None + @staticmethod + def resolve_credential_from_gh_cli(host: str | None) -> str | None: + """Resolve a token from the active gh CLI account for *host*. + + Uses ``gh auth token --hostname `` as a non-interactive fallback + before invoking OS credential helpers that may display UI. + + Eligibility is gated by :meth:`_supports_gh_cli_host` so all callers + share one path: hosts the gh CLI does not support (None/empty, ADO, + unrelated FQDNs) return ``None`` immediately without spawning a + subprocess. A non-zero exit, invalid output, missing ``gh`` binary, + or timeout all return ``None``; ``stderr`` is debug-logged on + non-zero exit so ``--verbose`` users can see why the call missed. + """ + if not GitHubTokenManager._supports_gh_cli_host(host): + return None + try: + result = subprocess.run( + ["gh", "auth", "token", "--hostname", host], + capture_output=True, + text=True, + encoding="utf-8", + timeout=GitHubTokenManager._get_credential_timeout(), + stdin=subprocess.DEVNULL, + env={ + **os.environ, + "GH_PROMPT_DISABLED": "1", + "GH_NO_UPDATE_NOTIFIER": "1", + }, + ) + if result.returncode != 0: + logger.debug( + "gh auth token failed for %s: %s", + host, + (result.stderr or "").strip()[:200], + ) + return None + + token = result.stdout.strip() + if token and GitHubTokenManager._is_valid_credential_token(token): + return token + return None + except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as exc: + logger.debug("gh auth token errored for %s: %s", host, exc) + return None + def setup_environment(self, env: dict[str, str] | None = None) -> dict[str, str]: """Set up complete token environment for all runtimes. @@ -214,9 +288,10 @@ def get_token_with_credential_fallback( """Get token for a purpose, falling back to git credential helpers. Tries environment variables first (via get_token_for_purpose), then - queries the git credential store as a last resort. Results are cached - per ``(host, port)`` to avoid repeated subprocess calls while keeping - same-host-different-port credentials separate. + checks the active gh CLI account, then queries the git credential + store as a last resort. Results are cached per ``(host, port)`` to + avoid repeated subprocess calls while keeping same-host-different-port + credentials separate. Args: purpose: Token purpose ('modules', etc.) @@ -237,6 +312,13 @@ def get_token_with_credential_fallback( if cache_key in self._credential_cache: return self._credential_cache[cache_key] + gh_token = None + if self._supports_gh_cli_host(host): + gh_token = self.resolve_credential_from_gh_cli(host) + if gh_token: + self._credential_cache[cache_key] = gh_token + return gh_token + credential = self.resolve_credential_from_git(host, port=port) self._credential_cache[cache_key] = credential return credential diff --git a/tests/test_token_manager.py b/tests/test_token_manager.py index fc1899619..c3958f341 100644 --- a/tests/test_token_manager.py +++ b/tests/test_token_manager.py @@ -2,6 +2,7 @@ import os import subprocess +import sys from unittest.mock import MagicMock, patch import pytest # noqa: F401 @@ -151,7 +152,8 @@ def test_git_askpass_set_to_empty(self): with patch("subprocess.run", return_value=mock_result) as mock_run: GitHubTokenManager.resolve_credential_from_git("github.com") call_env = mock_run.call_args.kwargs["env"] - assert call_env["GIT_ASKPASS"] == "" + expected = "echo" if sys.platform == "win32" else "" + assert call_env["GIT_ASKPASS"] == expected def test_rejects_password_prompt_as_token(self): """Rejects 'Password for ...' prompt text echoed back by GIT_ASKPASS.""" @@ -219,6 +221,73 @@ def test_accepts_valid_gho_token(self): assert token == "gho_abc123def456" +class TestResolveCredentialFromGhCli: + """Test resolve_credential_from_gh_cli static method.""" + + def test_success_returns_token(self): + mock_result = MagicMock(returncode=0, stdout="gho_cli_token\n") + with patch("subprocess.run", return_value=mock_result) as mock_run: + token = GitHubTokenManager.resolve_credential_from_gh_cli("github.com") + assert token == "gho_cli_token" + assert mock_run.call_args.args[0] == ["gh", "auth", "token", "--hostname", "github.com"] + kwargs = mock_run.call_args.kwargs + assert kwargs["env"]["GH_PROMPT_DISABLED"] == "1" + assert kwargs["env"]["GH_NO_UPDATE_NOTIFIER"] == "1" + assert kwargs["stdin"] is subprocess.DEVNULL + + def test_ineligible_host_skips_subprocess(self): + """ADO/empty/unrelated hosts must short-circuit without spawning gh.""" + with patch("subprocess.run") as mock_run: + assert GitHubTokenManager.resolve_credential_from_gh_cli(None) is None + assert GitHubTokenManager.resolve_credential_from_gh_cli("") is None + assert GitHubTokenManager.resolve_credential_from_gh_cli("dev.azure.com") is None + mock_run.assert_not_called() + + def test_nonzero_exit_returns_none(self): + mock_result = MagicMock(returncode=1, stdout="", stderr="not logged in") + with patch("subprocess.run", return_value=mock_result): + assert GitHubTokenManager.resolve_credential_from_gh_cli("github.com") is None + + def test_invalid_output_returns_none(self): + mock_result = MagicMock(returncode=0, stdout="Username for 'https://github.com':\n") + with patch("subprocess.run", return_value=mock_result): + assert GitHubTokenManager.resolve_credential_from_gh_cli("github.com") is None + + def test_timeout_returns_none(self): + with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="gh", timeout=5)): + assert GitHubTokenManager.resolve_credential_from_gh_cli("github.com") is None + + +class TestSupportsGhCliHost: + """Eligibility guard for the gh CLI fallback.""" + + def test_none_and_empty_unsupported(self): + assert GitHubTokenManager._supports_gh_cli_host(None) is False + assert GitHubTokenManager._supports_gh_cli_host("") is False + + def test_ado_unsupported(self): + assert GitHubTokenManager._supports_gh_cli_host("dev.azure.com") is False + + def test_github_com_supported(self): + assert GitHubTokenManager._supports_gh_cli_host("github.com") is True + + def test_ghe_cloud_supported(self): + assert GitHubTokenManager._supports_gh_cli_host("acme.ghe.com") is True + + def test_ghes_supported_when_matches_default_host(self): + with patch.dict(os.environ, {"GITHUB_HOST": "github.acme.com"}, clear=False): + assert GitHubTokenManager._supports_gh_cli_host("github.acme.com") is True + + def test_ghes_unsupported_when_mismatches_default_host(self): + with patch.dict(os.environ, {"GITHUB_HOST": "github.acme.com"}, clear=False): + assert GitHubTokenManager._supports_gh_cli_host("github.other.com") is False + + def test_ghes_unsupported_when_no_default_host(self): + env = {k: v for k, v in os.environ.items() if k != "GITHUB_HOST"} + with patch.dict(os.environ, env, clear=True): + assert GitHubTokenManager._supports_gh_cli_host("github.acme.com") is False + + class TestCredentialTimeout: """Tests for configurable git credential fill timeout.""" @@ -298,62 +367,120 @@ def test_returns_env_token_without_credential_fill(self): """Returns env var token and never calls credential fill.""" with patch.dict(os.environ, {"GITHUB_APM_PAT": "env-token"}, clear=True): manager = GitHubTokenManager() - with patch.object(GitHubTokenManager, "resolve_credential_from_git") as mock_cred: + with ( + patch.object(GitHubTokenManager, "resolve_credential_from_gh_cli") as mock_gh, + patch.object(GitHubTokenManager, "resolve_credential_from_git") as mock_cred, + ): token = manager.get_token_with_credential_fallback("modules", "github.com") assert token == "env-token" + mock_gh.assert_not_called() + mock_cred.assert_not_called() + + def test_falls_back_to_gh_cli_before_credential_fill(self): + """Uses gh CLI before git credential helpers when no env token exists.""" + with patch.dict(os.environ, {}, clear=True): + manager = GitHubTokenManager() + with ( + patch.object( + GitHubTokenManager, "resolve_credential_from_gh_cli", return_value="gh-token" + ) as mock_gh, + patch.object(GitHubTokenManager, "resolve_credential_from_git") as mock_cred, + ): + token = manager.get_token_with_credential_fallback("modules", "github.com") + assert token == "gh-token" + mock_gh.assert_called_once_with("github.com") mock_cred.assert_not_called() def test_falls_back_to_credential_fill(self): """Falls back to resolve_credential_from_git when no env token.""" with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() - with patch.object( - GitHubTokenManager, "resolve_credential_from_git", return_value="cred-token" - ) as mock_cred: + with ( + patch.object( + GitHubTokenManager, "resolve_credential_from_gh_cli", return_value=None + ) as mock_gh, + patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value="cred-token" + ) as mock_cred, + ): token = manager.get_token_with_credential_fallback("modules", "github.com") assert token == "cred-token" + mock_gh.assert_called_once_with("github.com") mock_cred.assert_called_once_with("github.com", port=None) def test_caches_credential_result(self): """Second call uses cache, subprocess not invoked again.""" with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() - with patch.object( - GitHubTokenManager, "resolve_credential_from_git", return_value="cached-tok" - ) as mock_cred: + with ( + patch.object( + GitHubTokenManager, "resolve_credential_from_gh_cli", return_value=None + ) as mock_gh, + patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value="cached-tok" + ) as mock_cred, + ): first = manager.get_token_with_credential_fallback("modules", "github.com") second = manager.get_token_with_credential_fallback("modules", "github.com") assert first == second == "cached-tok" + mock_gh.assert_called_once_with("github.com") mock_cred.assert_called_once() def test_caches_none_results(self): """None results are cached to avoid retrying failed lookups.""" with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() - with patch.object( - GitHubTokenManager, "resolve_credential_from_git", return_value=None - ) as mock_cred: + with ( + patch.object( + GitHubTokenManager, "resolve_credential_from_gh_cli", return_value=None + ) as mock_gh, + patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ) as mock_cred, + ): first = manager.get_token_with_credential_fallback("modules", "github.com") second = manager.get_token_with_credential_fallback("modules", "github.com") assert first is None assert second is None + mock_gh.assert_called_once_with("github.com") mock_cred.assert_called_once() def test_different_hosts_separate_cache(self): """Different hosts get independent cache entries.""" with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() - with patch.object( - GitHubTokenManager, - "resolve_credential_from_git", - side_effect=lambda h, port=None: f"tok-{h}", - ) as mock_cred: + with ( + patch.object( + GitHubTokenManager, "resolve_credential_from_gh_cli", return_value=None + ) as mock_gh, + patch.object( + GitHubTokenManager, + "resolve_credential_from_git", + side_effect=lambda h, port=None: f"tok-{h}", + ) as mock_cred, + ): tok1 = manager.get_token_with_credential_fallback("modules", "github.com") tok2 = manager.get_token_with_credential_fallback("modules", "gitlab.com") assert tok1 == "tok-github.com" assert tok2 == "tok-gitlab.com" + mock_gh.assert_called_once_with("github.com") assert mock_cred.call_count == 2 + def test_non_github_host_skips_gh_cli(self): + """Generic hosts should not invoke gh CLI fallback.""" + with patch.dict(os.environ, {}, clear=True): + manager = GitHubTokenManager() + with ( + patch.object(GitHubTokenManager, "resolve_credential_from_gh_cli") as mock_gh, + patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value="cred-token" + ) as mock_cred, + ): + token = manager.get_token_with_credential_fallback("modules", "gitlab.com") + assert token == "cred-token" + mock_gh.assert_not_called() + mock_cred.assert_called_once_with("gitlab.com", port=None) + def test_same_host_different_ports_separate_cache(self): """Same host on different ports must not cross-contaminate credentials.""" with patch.dict(os.environ, {}, clear=True): diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index b01b2f822..12c2b1114 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -10,6 +10,7 @@ from apm_cli.core import azure_cli as _azure_cli_mod from apm_cli.core.auth import AuthContext, AuthResolver, HostInfo # noqa: F401 from apm_cli.core.token_manager import GitHubTokenManager +from apm_cli.models.dependency.reference import DependencyReference @pytest.fixture(autouse=True) @@ -21,6 +22,13 @@ def _reset_bearer_singleton(): _azure_cli_mod._provider_singleton = None +@pytest.fixture(autouse=True) +def _disable_gh_cli_fallback(): + """Keep auth tests deterministic regardless of local gh login state.""" + with patch.object(GitHubTokenManager, "resolve_credential_from_gh_cli", return_value=None): + yield + + # --------------------------------------------------------------------------- # TestClassifyHost # --------------------------------------------------------------------------- @@ -217,6 +225,72 @@ def test_credential_fallback(self): assert ctx.token == "cred-token" assert ctx.source == "git-credential-fill" + def test_gh_cli_source_label(self): + """When gh CLI supplies the token, ctx.source == 'gh-auth-token'.""" + with ( + patch.dict(os.environ, {}, clear=True), + patch.object( + GitHubTokenManager, + "resolve_credential_from_gh_cli", + return_value="gho_cli_token", + ), + ): + resolver = AuthResolver() + ctx = resolver.resolve("github.com") + assert ctx.token == "gho_cli_token" + assert ctx.source == "gh-auth-token" + + def test_try_with_fallback_uses_gh_cli(self): + """try_with_fallback retries via gh CLI before git credential fill.""" + with ( + patch.dict(os.environ, {"GITHUB_APM_PAT": "stale-token"}, clear=True), + patch.object( + GitHubTokenManager, + "resolve_credential_from_gh_cli", + return_value="gho_fresh", + ), + patch.object( + GitHubTokenManager, "resolve_credential_from_git", return_value=None + ) as mock_cred, + ): + resolver = AuthResolver() + attempts = [] + + def op(token, env): + attempts.append(token) + if token == "gho_fresh": + return token + raise RuntimeError("401 Unauthorized") + + result = resolver.try_with_fallback("github.com", op) + assert result == "gho_fresh" + assert attempts == ["stale-token", None, "gho_fresh"] + # git credential fill must not be reached when gh CLI succeeds. + mock_cred.assert_not_called() + + def test_resolve_for_dep_uses_standard_credential_fallback(self): + """Dependency-aware resolution still uses the standard host-based fallback chain.""" + dep_ref = DependencyReference.parse("Devolutions/RDM/.claude/skills/add-culture-rdm") + with patch.dict(os.environ, {}, clear=True): + with ( + patch.object( + GitHubTokenManager, + "resolve_credential_from_gh_cli", + return_value=None, + ) as mock_gh, + patch.object( + GitHubTokenManager, + "resolve_credential_from_git", + return_value="cred-token", + ) as mock_cred, + ): + resolver = AuthResolver() + ctx = resolver.resolve_for_dep(dep_ref) + assert ctx.token == "cred-token" + assert ctx.source == "git-credential-fill" + mock_gh.assert_called_once_with("github.com") + mock_cred.assert_called_once_with("github.com", port=None) + def test_global_var_resolves_for_non_default_host(self): """GITHUB_APM_PAT resolves for *.ghe.com (any host, not just default).""" with patch.dict(os.environ, {"GITHUB_APM_PAT": "global-token"}, clear=True):