From 19ac1e20a3bae65fe68fefc4ea65eb482e381063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 8 Apr 2026 15:39:12 -0400 Subject: [PATCH 1/3] fix(auth): try gh auth token before git credential fill Add a GitHub CLI fallback for GitHub-like hosts before invoking git credential fill. Update the auth resolution docs and focused auth tests to match the narrower fallback chain. --- .../docs/getting-started/authentication.md | 33 +++++---- .../.apm/skills/apm-usage/authentication.md | 5 +- src/apm_cli/core/auth.py | 31 +++++--- src/apm_cli/core/token_manager.py | 67 +++++++++++++++-- tests/test_token_manager.py | 71 ++++++++++++++++++- tests/unit/test_auth.py | 28 ++++++++ 6 files changed, 206 insertions(+), 29 deletions(-) diff --git a/docs/src/content/docs/getting-started/authentication.md b/docs/src/content/docs/getting-started/authentication.md index 52fed8138..414924d12 100644 --- a/docs/src/content/docs/getting-started/authentication.md +++ b/docs/src/content/docs/getting-started/authentication.md @@ -12,9 +12,10 @@ 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) +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). +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. @@ -28,7 +29,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. @@ -297,21 +299,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?} + 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/packages/apm-guide/.apm/skills/apm-usage/authentication.md b/packages/apm-guide/.apm/skills/apm-usage/authentication.md index 27a7d3f05..9e9758b2e 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/authentication.md +++ b/packages/apm-guide/.apm/skills/apm-usage/authentication.md @@ -10,7 +10,10 @@ 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 | + +APM checks the active `gh` CLI account before invoking OS credential helpers. This reduces ambiguous multi-account prompts on hosts like github.com. | -- | None | -- | Unauthenticated (public GitHub repos only) | ## Per-org setup diff --git a/src/apm_cli/core/auth.py b/src/apm_cli/core/auth.py index b8d9d31d7..faac309db 100644 --- a/src/apm_cli/core/auth.py +++ b/src/apm_cli/core/auth.py @@ -325,7 +325,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 @@ -342,15 +343,22 @@ def _try_credential_fallback(exc: Exception) -> T: # 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}" - ) + _log(f"Token from {auth_ctx.source} failed, trying fallback credentials for {host_info.display_name}") + if host_info.kind in ("github", "ghe_cloud", "ghes"): + gh_token = self._token_manager.resolve_credential_from_gh_cli(host_info.host) + if gh_token: + return operation( + gh_token, + self._build_git_env(gh_token, scheme="basic", host_kind=host_info.kind), + ) 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)) + 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) @@ -600,7 +608,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"`` @@ -647,7 +656,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 (GitHub-like hosts only) + if host_info.kind in ("github", "ghe_cloud", "ghes"): + 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..df68bb58f 100644 --- a/src/apm_cli/core/token_manager.py +++ b/src/apm_cli/core/token_manager.py @@ -11,7 +11,7 @@ - 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: @@ -23,6 +23,13 @@ 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, +) + def _format_credential_host(host: str, port: int | None) -> str: """Embed a custom port into the git credential ``host`` field. @@ -93,6 +100,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 +184,32 @@ 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) -> str | None: + """Resolve a token from the active gh CLI account for the host. + + Uses `gh auth token --hostname ` as a non-interactive fallback + before invoking OS credential helpers that may display UI. + """ + try: + result = subprocess.run( + ["gh", "auth", "token", "--hostname", host], + capture_output=True, + text=True, + encoding="utf-8", + timeout=GitHubTokenManager._get_credential_timeout(), + env={**os.environ, "GH_PROMPT_DISABLED": "1"}, + ) + if result.returncode != 0: + return None + + token = result.stdout.strip() + if token and GitHubTokenManager._is_valid_credential_token(token): + return token + return None + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + 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 +265,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 +289,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..ec1ee12e8 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,32 @@ 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"] + assert mock_run.call_args.kwargs["env"]["GH_PROMPT_DISABLED"] == "1" + + 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 TestCredentialTimeout: """Tests for configurable git credential fill timeout.""" @@ -298,9 +326,24 @@ 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): @@ -308,10 +351,13 @@ def test_falls_back_to_credential_fill(self): with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() 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): @@ -319,11 +365,14 @@ def test_caches_credential_result(self): with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() 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): @@ -331,12 +380,15 @@ def test_caches_none_results(self): with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() 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): @@ -344,6 +396,8 @@ def test_different_hosts_separate_cache(self): with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() 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}", @@ -352,8 +406,21 @@ def test_different_hosts_separate_cache(self): 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 a6f752370..f1482414e 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,26 @@ def test_credential_fallback(self): assert ctx.token == "cred-token" assert ctx.source == "git-credential-fill" + 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): From 49dda9af10319a04236f46737f0f74646f90d846 Mon Sep 17 00:00:00 2001 From: Daniel Meppiel Date: Fri, 8 May 2026 23:24:51 +0200 Subject: [PATCH 2/3] style: fix ruff formatting for CI lint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/apm_cli/core/auth.py | 4 +- tests/test_token_manager.py | 81 +++++++++++++++++++++++-------------- tests/unit/test_auth.py | 21 +++++----- 3 files changed, 65 insertions(+), 41 deletions(-) diff --git a/src/apm_cli/core/auth.py b/src/apm_cli/core/auth.py index 0cb2c51c2..7f9445667 100644 --- a/src/apm_cli/core/auth.py +++ b/src/apm_cli/core/auth.py @@ -362,7 +362,9 @@ def _try_credential_fallback(exc: Exception) -> T: # 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 fallback credentials for {host_info.display_name}") + _log( + f"Token from {auth_ctx.source} failed, trying fallback credentials for {host_info.display_name}" + ) if host_info.kind in ("github", "ghe_cloud", "ghes"): gh_token = self._token_manager.resolve_credential_from_gh_cli(host_info.host) if gh_token: diff --git a/tests/test_token_manager.py b/tests/test_token_manager.py index ec1ee12e8..4ff565290 100644 --- a/tests/test_token_manager.py +++ b/tests/test_token_manager.py @@ -326,9 +326,10 @@ 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_gh_cli") as mock_gh, 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() @@ -338,9 +339,12 @@ 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: + 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") @@ -350,11 +354,14 @@ 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_gh_cli", return_value=None - ) as mock_gh, 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") @@ -364,11 +371,14 @@ 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_gh_cli", return_value=None - ) as mock_gh, 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" @@ -379,11 +389,14 @@ 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_gh_cli", return_value=None - ) as mock_gh, 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 @@ -395,13 +408,16 @@ 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_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: + 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" @@ -413,9 +429,12 @@ 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: + 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() diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index 354bda07f..fee1dc34d 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -229,15 +229,18 @@ 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: + 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" From 74d4e782dc0a60b6d987ff2ee29ee245922fddf7 Mon Sep 17 00:00:00 2001 From: Daniel Meppiel Date: Sat, 9 May 2026 17:06:20 +0200 Subject: [PATCH 3/3] Address PR #630 panel review Code: - Fold gh-CLI eligibility into resolve_credential_from_gh_cli; both call sites now share _supports_gh_cli_host semantics, eliminating the GHES-default-host divergence between auth.py and token_manager. - Harden gh subprocess: stdin=DEVNULL, GH_NO_UPDATE_NOTIFIER=1, and debug-log stderr on non-zero exit so --verbose users can see why the call missed. - Add 'gh-auth-token' to _try_credential_fallback short-circuit set to prevent double-invocation when the original token came from gh. - Per-step verbose logging in fallback chain ('trying gh auth token for X' then 'trying git credential fill for X') and updated docstring to cover gh CLI. Tests (3 new gaps from review): - TestSupportsGhCliHost: None/empty/ADO/github.com/*.ghe.com/GHES match/GHES mismatch/no GITHUB_HOST. - test_gh_cli_source_label: asserts AuthContext.source == 'gh-auth-token' when gh CLI supplies the token. - test_try_with_fallback_uses_gh_cli: exercises the gh-CLI branch through try_with_fallback, escaping the autouse disable fixture. - Augmented existing TestResolveCredentialFromGhCli with assertions for stdin=DEVNULL and GH_NO_UPDATE_NOTIFIER=1, plus a guard test that ineligible hosts skip the subprocess entirely. Docs: - packages/apm-guide/.apm/skills/apm-usage/authentication.md: move the orphaned '| -- | None |' row inside the table (was after the prose, breaking the table) and place the Note paragraph after the complete 7-row table. Add silent-skip behavior note. - docs/.../getting-started/authentication.md: reconcile prose-vs- table priority numbering (prose collapses 3 env vars into one step), update Package source behavior table to mention gh auth token, annotate mermaid F node 'GitHub-like hosts only', add silent-skip note. - docs/.../getting-started/quick-start.md: add ':::tip' callout near the install step for gh CLI users. - Reconciled stale '30s' timeout drift to '60s' to match DEFAULT_CREDENTIAL_TIMEOUT. CHANGELOG: framed as user promise under [Unreleased] -> Added, tagged (#630). Verified: ruff check + format silent; 140 tests pass. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + .../docs/getting-started/authentication.md | 14 +++--- .../docs/getting-started/quick-start.md | 4 ++ .../.apm/skills/apm-usage/authentication.md | 6 +-- src/apm_cli/core/auth.py | 41 +++++++++++------- src/apm_cli/core/token_manager.py | 33 +++++++++++--- tests/test_token_manager.py | 43 ++++++++++++++++++- tests/unit/test_auth.py | 43 +++++++++++++++++++ 8 files changed, 155 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f22033a06..4202577e5 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 f440ec862..52023ed75 100644 --- a/docs/src/content/docs/getting-started/authentication.md +++ b/docs/src/content/docs/getting-started/authentication.md @@ -12,9 +12,11 @@ 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. **GitHub CLI active account** — `gh auth token --hostname ` (GitHub-like hosts) +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) +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. @@ -205,10 +207,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` | @@ -304,7 +306,7 @@ 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{gh auth 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] 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 e2f1242ff..d69777dab 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/authentication.md +++ b/packages/apm-guide/.apm/skills/apm-usage/authentication.md @@ -12,10 +12,10 @@ APM checks these sources in order, using the first valid token found: | 4 | `GH_TOKEN` | Global | Set by `gh auth login` | | 5 | `gh auth token --hostname ` | GitHub-like hosts | Active `gh auth login` account | | 6 | `git credential fill` | Per-host | System credential manager | - -APM checks the active `gh` CLI account before invoking OS credential helpers. This reduces ambiguous multi-account prompts on hosts like github.com. | -- | 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: @@ -146,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 7f9445667..a2918a278 100644 --- a/src/apm_cli/core/auth.py +++ b/src/apm_cli/core/auth.py @@ -356,26 +356,37 @@ 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 fallback credentials for {host_info.display_name}" + f"Token from {auth_ctx.source} failed for {host_info.display_name}; " + "trying secondary credential sources" ) - if host_info.kind in ("github", "ghe_cloud", "ghes"): - gh_token = self._token_manager.resolve_credential_from_gh_cli(host_info.host) - if gh_token: - return operation( - gh_token, - self._build_git_env(gh_token, scheme="basic", host_kind=host_info.kind), - ) + _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: + _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), @@ -699,11 +710,11 @@ def _resolve_token(self, host_info: HostInfo, org: str | None) -> tuple[str | No source = self._identify_env_source(purpose) return token, source, "basic" - # 3. gh CLI active account (GitHub-like hosts only) - if host_info.kind in ("github", "ghe_cloud", "ghes"): - gh_token = self._token_manager.resolve_credential_from_gh_cli(host_info.host) - if gh_token: - return gh_token, "gh-auth-token", "basic" + # 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",): diff --git a/src/apm_cli/core/token_manager.py b/src/apm_cli/core/token_manager.py index df68bb58f..05af3043a 100644 --- a/src/apm_cli/core/token_manager.py +++ b/src/apm_cli/core/token_manager.py @@ -18,6 +18,7 @@ - Codex CLI: Uses GITHUB_TOKEN (must be user-scoped for GitHub Models) """ +import logging import os import subprocess import sys @@ -30,6 +31,8 @@ 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. @@ -185,12 +188,21 @@ def resolve_credential_from_git(host: str, port: int | None = None) -> str | Non return None @staticmethod - def resolve_credential_from_gh_cli(host: str) -> str | None: - """Resolve a token from the active gh CLI account for the host. + 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 + 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], @@ -198,16 +210,27 @@ def resolve_credential_from_gh_cli(host: str) -> str | None: text=True, encoding="utf-8", timeout=GitHubTokenManager._get_credential_timeout(), - env={**os.environ, "GH_PROMPT_DISABLED": "1"}, + 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): + 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]: diff --git a/tests/test_token_manager.py b/tests/test_token_manager.py index 4ff565290..c3958f341 100644 --- a/tests/test_token_manager.py +++ b/tests/test_token_manager.py @@ -230,7 +230,18 @@ def test_success_returns_token(self): 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"] - assert mock_run.call_args.kwargs["env"]["GH_PROMPT_DISABLED"] == "1" + 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") @@ -247,6 +258,36 @@ def test_timeout_returns_none(self): 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.""" diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index fee1dc34d..12c2b1114 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -225,6 +225,49 @@ 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")