`run_git_with_credential_fallback` sent exactly one stored credential after an anonymous refusal: the first one `resolve_git_basic_auth` found, which is `GITHUB_TOKEN`/`GH_TOKEN` from .env whenever it is set. An expired classic PAT left there (older setup flows encouraged it) therefore shadowed a working `gh auth login` for every private plugin/MCP/profile clone, and the failure read as git's generic "could not read Username ... terminal prompts disabled". Why: the remote, not the resolution order, knows which credential is live. The run now walks every credential the user owns for the host (.env token, `gh auth token`, git credential helper) until one is accepted or the failure stops being about credentials, logs which source was rejected, and appends an actionable hint naming the .env token when GitHub refused all of them. `gh auth token` is asked with GH_TOKEN/GITHUB_TOKEN stripped from its environment, otherwise it echoes the exported dead token back instead of its keyring login and the fallback dedupes to nothing. Test file also gets encoding="utf-8" on its bare read_text/write_text calls (windows-footgun scanner population for the touched file).
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())
|
|
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.<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)
|
|
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
|