"""Non-interactive HTTPS credentials for Hermes's internal git clones (private plugin/MCP/profile repos). :func:`noninteractive_git_env` deliberately disables credential helpers, askpass and global git config so a hostile repo cannot make our plumbing prompt or hang. The cost is that a *private* repo the user can already clone from their shell fails inside ``hermes plugins install`` with "could not read Username" (or hangs on a GUI askpass until the timeout). This module resolves a credential up front, from sources the user already owns, and passes it to git as a one-shot ``http./.extraheader`` in the environment — never in the URL and never in ``.git/config``, so nothing is persisted into the installed checkout. Resolution order for an ``https://`` URL: 1. ``GITHUB_TOKEN`` / ``GH_TOKEN`` (profile-scoped, GitHub hosts only). 2. ``gh auth token`` (GitHub hosts only; the gh CLI's own login). 3. ``git credential fill`` against the user's configured credential helpers (any host: GitLab, Bitbucket, self-hosted) with prompting disabled, so a stored credential is returned and a missing one fails in ~100 ms instead of asking. The credential is attached only after an anonymous attempt is refused (:func:`run_git_with_credential_fallback`), and a refused credential yields to the next one in the list rather than ending the run — a stale ``GITHUB_TOKEN`` in ``.env`` otherwise shadows a ``gh auth login`` that still works. Most catalog repos are public, and a stale or revoked stored token sent pre-emptively turns a clone that works anonymously into a 401 that git can only answer with the prompt this module disables — "could not read Username for 'https://github.com': terminal prompts disabled". """ from __future__ import annotations import base64 import logging import os import re import shutil import subprocess import urllib.parse from typing import Iterator, Mapping, Optional from hermes_cli._subprocess_compat import noninteractive_git_env, windows_hide_flags logger = logging.getLogger(__name__) _GITHUB_HOSTS = {"github.com", "gist.github.com"} def _https_origin(url: str) -> Optional[str]: parsed = urllib.parse.urlsplit(url) if parsed.scheme != "https" or not parsed.hostname: return None host = f"[{parsed.hostname}]" if ":" in parsed.hostname else parsed.hostname if parsed.port is not None: host = f"{host}:{parsed.port}" return f"https://{host}" def _env_github_token() -> Optional[str]: from agent.secret_scope import get_secret return get_secret("GITHUB_TOKEN") or get_secret("GH_TOKEN") or None def _gh_cli_token() -> Optional[str]: gh = shutil.which("gh") if not gh: return None try: env = noninteractive_git_env() env["GH_PROMPT_DISABLED"] = "1" # gh echoes an exported GH_TOKEN/GITHUB_TOKEN back instead of its keyring login; that # token is already candidate 1, and a dead one would hide the login that still works. for var in ("GH_TOKEN", "GITHUB_TOKEN"): env.pop(var, None) result = subprocess.run( [gh, "auth", "token"], capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=10, stdin=subprocess.DEVNULL, env=env, creationflags=windows_hide_flags()) except (OSError, subprocess.TimeoutExpired) as exc: logger.debug("gh auth token lookup failed: %s", exc) return None if result.returncode != 0: return None return result.stdout.strip() or None def _github_token() -> Optional[str]: return _env_github_token() or _gh_cli_token() def _credential_fill(origin: str) -> Optional[tuple[str, str]]: """``(username, password)`` from the user's own git credential helpers, never prompting.""" git = shutil.which("git") if not git: return None env = dict(os.environ) env["GIT_TERMINAL_PROMPT"] = "0" env["GCM_INTERACTIVE"] = "Never" # A GUI askpass (VS Code, ssh-askpass) would block on a dialog nobody sees. env.pop("GIT_ASKPASS", None) env.pop("SSH_ASKPASS", None) parsed = urllib.parse.urlsplit(origin) request = f"protocol=https\nhost={parsed.netloc}\n\n" try: result = subprocess.run( [git, "-c", "core.askPass=", "credential", "fill"], input=request, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=15, env=env, creationflags=windows_hide_flags()) except (OSError, subprocess.TimeoutExpired) as exc: logger.debug("git credential fill failed for %s: %s", origin, exc) return None if result.returncode != 0: return None fields = dict(line.split("=", 1) for line in result.stdout.splitlines() if "=" in line) if fields.get("password"): return fields.get("username", ""), fields["password"] return None def iter_git_basic_auth(url: str) -> Iterator[tuple[str, tuple[str, str]]]: """``(source, (username, password))`` for every credential the user owns for *url*'s host, in precedence order and without duplicates. The remote decides which one works: an expired ``GITHUB_TOKEN`` left in ``.env`` must not shadow a live ``gh auth login`` (#115257).""" origin = _https_origin(url) if origin is None: return seen: set[tuple[str, str]] = set() sources = [] if urllib.parse.urlsplit(origin).hostname in _GITHUB_HOSTS: sources += [("GITHUB_TOKEN/GH_TOKEN", lambda: _token_pair(_env_github_token())), ("gh auth token", lambda: _token_pair(_gh_cli_token()))] sources.append(("git credential helper", lambda: _credential_fill(origin))) for source, lookup in sources: auth = lookup() if auth is not None and auth not in seen: seen.add(auth) yield source, auth def _token_pair(token: Optional[str]) -> Optional[tuple[str, str]]: return ("x-access-token", token) if token else None def resolve_git_basic_auth(url: str) -> Optional[tuple[str, str]]: """``(username, password)`` for *url*, or None for non-HTTPS URLs / no stored credential.""" return next((auth for _source, auth in iter_git_basic_auth(url)), None) def with_git_auth(env: Mapping[str, str], url: str, auth: Optional[tuple[str, str]] = None) -> dict[str, str]: """Copy of *env* (a :func:`noninteractive_git_env` result) that authenticates HTTPS requests to *url*'s origin via a ``GIT_CONFIG_*`` ``http./.extraheader`` entry when a credential is available (*auth*, else the first stored one); unchanged otherwise. The header lives only in this process environment.""" env = dict(env) origin = _https_origin(url) if origin is None: return env if auth is None: auth = resolve_git_basic_auth(url) if auth is None: return env encoded = base64.b64encode(f"{auth[0]}:{auth[1]}".encode()).decode() idx = int(env.get("GIT_CONFIG_COUNT", "0") or 0) env[f"GIT_CONFIG_KEY_{idx}"] = f"http.{origin}/.extraheader" env[f"GIT_CONFIG_VALUE_{idx}"] = f"Authorization: basic {encoded}" env["GIT_CONFIG_COUNT"] = str(idx + 1) return env def _auth_headers(env: Mapping[str, str]) -> set[str]: return {v for k, v in env.items() if k.startswith("GIT_CONFIG_VALUE_") and v.startswith("Authorization:")} # What git prints when the remote wants a credential it could not obtain: the prompt that # GIT_TERMINAL_PROMPT=0 refused, a rejected credential, or a bare 401/403 from the server. _CREDENTIAL_REQUIRED_RE = re.compile( r"could not read (?:username|password)|terminal prompts disabled|authentication failed" r"|(?:error|status|http)[: ]+40[13]\b", re.IGNORECASE, ) def is_credential_required_error(result: subprocess.CompletedProcess) -> bool: """True when a failed git run says the remote demands a credential (vs. a typo'd URL, a missing commit, a network error, ...).""" parts = [] for stream in (result.stderr, result.stdout): if isinstance(stream, bytes): stream = stream.decode("utf-8", errors="replace") parts.append(stream or "") return _CREDENTIAL_REQUIRED_RE.search("\n".join(parts)) is not None def run_git_with_credential_fallback( argv: list[str], url: str, *, env: Mapping[str, str], **run_kwargs, ) -> subprocess.CompletedProcess: """Run the git network verb *argv* against *url* anonymously; when the remote refuses with the credential-required class, rerun with each credential the user owns for that host, in precedence order, until one is accepted or the failure stops being about credentials. *env* is a :func:`noninteractive_git_env`; *run_kwargs* must capture output so the refusal can be classified. Empty *url* means no fallback (local verbs).""" run_kwargs.setdefault("stdin", subprocess.DEVNULL) env = dict(env) # An inherited askpass (VS Code terminal, ksshaskpass) would swallow the remote's 401 into a # dialog nobody answers: the run hits its timeout instead of failing with "could not read # Username", and the refusal below is never classified. Same drop _credential_fill does. env.pop("GIT_ASKPASS", None) env.pop("SSH_ASKPASS", None) result = subprocess.run(argv, env=env, **run_kwargs) if result.returncode == 0 or not url or not is_credential_required_error(result): return result auth_env = with_git_auth(env, url) if auth_env == env: return result result = subprocess.run(argv, env=auth_env, **run_kwargs) sent = _auth_headers(auth_env) rejected: list[str] = [] # The first credential (typically GITHUB_TOKEN from .env) was refused too: it is stale or # revoked, not missing. Try the remaining ones the user owns instead of failing on it. for source, auth in iter_git_basic_auth(url): if result.returncode == 0 or not is_credential_required_error(result): break candidate_env = with_git_auth(env, url, auth) headers = _auth_headers(candidate_env) if headers <= sent: rejected.append(source) continue sent |= headers logger.warning("%s rejected the credential from %s; retrying with %s", _https_origin(url), ", ".join(rejected) or "the stored credential", source) result = subprocess.run(argv, env=candidate_env, **run_kwargs) if result.returncode != 0 and is_credential_required_error(result): rejected.append(source) if result.returncode != 0 and "GITHUB_TOKEN/GH_TOKEN" in rejected and isinstance(result.stderr, str): result.stderr += ("\nhint: the GITHUB_TOKEN/GH_TOKEN in your .env was rejected by GitHub;" " replace it or remove it (gh auth login is used when it is absent).") return result