236 lines
11 KiB
Python
236 lines
11 KiB
Python
"""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.<origin>/.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(), check=False)
|
|
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(), check=False)
|
|
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.<origin>/.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) # noqa: PLW1510 -- forwarding wrapper: callers pass subprocess kwargs via **kwargs (check may arrive through it); an explicit check=False would raise TypeError
|
|
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) # noqa: PLW1510 -- forwarding wrapper: callers pass subprocess kwargs via **kwargs (check may arrive through it); an explicit check=False would raise TypeError
|
|
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) # noqa: PLW1510 -- forwarding wrapper: callers pass subprocess kwargs via **kwargs (check may arrive through it); an explicit check=False would raise TypeError
|
|
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
|