feat(plugins): install plugins from private git repos using the user's stored credentials
`hermes plugins install owner/private-repo` failed for every private repository even when the same user could `git clone` it from their shell: the hardened `noninteractive_git_env` disables credential helpers, askpass and global config (so a hostile repo can't make our plumbing prompt or hang), which also blocks the user's own stored credential. The result was "could not read Username" or a 60s hang on a GUI askpass, with no hint about how to authenticate. New `hermes_cli/git_credentials.py` resolves a credential up front from sources the user already owns — GITHUB_TOKEN/GH_TOKEN (profile-scoped), `gh auth token`, then `git credential fill` against their configured helpers with prompting disabled (any host) — and hands it to git as a one-shot `http.<origin>/.extraheader` via the GIT_CONFIG_* env block. Nothing lands in the URL, `.git/config` or install metadata. The same path covers `plugins update`, catalog MCP git installs and profile-distribution staging, which share the same hardened env and the same failure. A private-repo clone with no credential now fails fast with an actionable hint.
This commit is contained in:
125
hermes_cli/git_credentials.py
Normal file
125
hermes_cli/git_credentials.py
Normal file
@@ -0,0 +1,125 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.parse
|
||||
from typing import Mapping, Optional
|
||||
|
||||
from hermes_cli._subprocess_compat import 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 _github_token() -> Optional[str]:
|
||||
from agent.secret_scope import get_secret
|
||||
|
||||
token = get_secret("GITHUB_TOKEN") or get_secret("GH_TOKEN")
|
||||
if token:
|
||||
return token
|
||||
gh = shutil.which("gh")
|
||||
if not gh:
|
||||
return None
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[gh, "auth", "token"], capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||
timeout=10, stdin=subprocess.DEVNULL, 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 _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 resolve_git_basic_auth(url: str) -> Optional[tuple[str, str]]:
|
||||
"""``(username, password)`` for *url*, or None for non-HTTPS URLs / no stored credential."""
|
||||
origin = _https_origin(url)
|
||||
if origin is None:
|
||||
return None
|
||||
if urllib.parse.urlsplit(origin).hostname in _GITHUB_HOSTS:
|
||||
token = _github_token()
|
||||
if token:
|
||||
return "x-access-token", token
|
||||
return _credential_fill(origin)
|
||||
|
||||
|
||||
def with_git_auth(env: Mapping[str, str], url: str) -> 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; unchanged otherwise. The header lives only in this process environment."""
|
||||
env = dict(env)
|
||||
origin = _https_origin(url)
|
||||
if origin is None:
|
||||
return env
|
||||
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
|
||||
@@ -379,7 +379,8 @@ def _do_git_install(entry: CatalogEntry) -> Path:
|
||||
# upfront so the fast path doesn't always fail noisily before the full-clone fallback.
|
||||
is_sha_ref = bool(re.fullmatch(r"[0-9a-f]{7,40}", install.ref))
|
||||
# Never hang on a credential prompt: installs run from CLI/dashboard flows nobody can answer.
|
||||
_git_env = noninteractive_git_env()
|
||||
from hermes_cli.git_credentials import with_git_auth
|
||||
_git_env = with_git_auth(noninteractive_git_env(), install.url)
|
||||
|
||||
def _git(*args: str) -> int:
|
||||
return subprocess.run([git, *args], stdin=subprocess.DEVNULL, env=_git_env).returncode
|
||||
|
||||
@@ -463,10 +463,11 @@ def _safe_git_error(result: subprocess.CompletedProcess, source_url: str = "") -
|
||||
|
||||
|
||||
def _git_or_raise(
|
||||
git_exe: str, repo: Path, *args: str, failure_prefix: str, timeout: int = 60, source_url: str = ""
|
||||
git_exe: str, repo: Path, *args: str, failure_prefix: str, timeout: int = 60, source_url: str = "",
|
||||
auth_url: str = "",
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run git in *repo*; on a non-zero exit raise PluginOperationError(prefix + scrubbed error)."""
|
||||
result = _run_plugin_git(git_exe, repo, *args, timeout=timeout)
|
||||
result = _run_plugin_git(git_exe, repo, *args, timeout=timeout, auth_url=auth_url)
|
||||
if result.returncode != 0:
|
||||
raise PluginOperationError(failure_prefix + _safe_git_error(result, source_url))
|
||||
return result
|
||||
@@ -479,14 +480,15 @@ def _git_head_revision(repo: Path, git_exe: str) -> str:
|
||||
).stdout.strip().lower()
|
||||
|
||||
|
||||
def _checkout_exact_revision(repo: Path, git_exe: str, revision: str) -> None:
|
||||
def _checkout_exact_revision(repo: Path, git_exe: str, revision: str, source_url: str = "") -> None:
|
||||
"""Fetch and detach at one immutable commit, then verify the resulting HEAD."""
|
||||
for verb, args, failure_prefix in (
|
||||
("fetch", ("fetch", "--depth", "1", "origin", revision), f"Git commit '{revision}' could not be fetched:\n"),
|
||||
("checkout", ("checkout", "--detach", revision), f"Git checkout of commit '{revision}' failed:\n"),
|
||||
):
|
||||
try:
|
||||
_git_or_raise(git_exe, repo, *args, failure_prefix=failure_prefix)
|
||||
_git_or_raise(git_exe, repo, *args, failure_prefix=failure_prefix, source_url=source_url,
|
||||
auth_url=source_url if verb == "fetch" else "")
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise PluginOperationError(f"Git {verb} of commit '{revision}' timed out after 60 seconds.") from exc
|
||||
actual = _git_head_revision(repo, git_exe)
|
||||
@@ -548,16 +550,21 @@ def _clone_plugin_repo(tmp_clone: Path, git_url: str, revision: Optional[str]) -
|
||||
raise PluginOperationError("git is not installed or not in PATH.")
|
||||
clone_args = ["clone", "--depth", "1", *(["--no-checkout"] if revision else []), git_url, str(tmp_clone)]
|
||||
try:
|
||||
result = _run_plugin_git(git_exe, tmp_clone.parent, *clone_args)
|
||||
result = _run_plugin_git(git_exe, tmp_clone.parent, *clone_args, auth_url=git_url)
|
||||
except FileNotFoundError as e:
|
||||
raise PluginOperationError("git is not installed or not in PATH.") from e
|
||||
except subprocess.TimeoutExpired as e:
|
||||
raise PluginOperationError("Git clone timed out after 60 seconds.") from e
|
||||
if result.returncode != 0:
|
||||
raise PluginOperationError(f"Git clone failed:\n{_safe_git_error(result, git_url)}")
|
||||
error = _safe_git_error(result, git_url)
|
||||
if re.search(r"could not read Username|Authentication failed|Repository not found", error):
|
||||
error += (
|
||||
"\n\nIf this repository is private, authenticate first: run `gh auth login`, set GITHUB_TOKEN "
|
||||
"(or GH_TOKEN) in your .env, or store a credential in git's credential helper for this host.")
|
||||
raise PluginOperationError(f"Git clone failed:\n{error}")
|
||||
_scrub_cloned_origin(tmp_clone, git_exe, git_url)
|
||||
if revision:
|
||||
_checkout_exact_revision(tmp_clone, git_exe, revision)
|
||||
_checkout_exact_revision(tmp_clone, git_exe, revision, source_url=git_url)
|
||||
return _git_head_revision(tmp_clone, git_exe)
|
||||
|
||||
|
||||
@@ -1852,11 +1859,18 @@ def _clear_plugin_bytecode(target: Path) -> int:
|
||||
return removed
|
||||
|
||||
|
||||
def _run_plugin_git(git_exe: str, target: Path, *args: str, timeout: int = 60) -> subprocess.CompletedProcess:
|
||||
"""Run one git command inside a plugin checkout (non-interactive)."""
|
||||
def _run_plugin_git(
|
||||
git_exe: str, target: Path, *args: str, timeout: int = 60, auth_url: str = "",
|
||||
) -> subprocess.CompletedProcess:
|
||||
"""Run one git command inside a plugin checkout (non-interactive). *auth_url* names the remote
|
||||
a network verb talks to so a stored user credential for its host is attached (private repos)."""
|
||||
env = noninteractive_git_env()
|
||||
if auth_url:
|
||||
from hermes_cli.git_credentials import with_git_auth
|
||||
env = with_git_auth(env, auth_url)
|
||||
return subprocess.run(
|
||||
[git_exe, *args], capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout,
|
||||
cwd=str(target), stdin=subprocess.DEVNULL, env=noninteractive_git_env())
|
||||
cwd=str(target), stdin=subprocess.DEVNULL, env=env)
|
||||
|
||||
|
||||
def _stash_ref(git_exe: str, target: Path) -> str:
|
||||
@@ -1916,7 +1930,8 @@ def _git_pull_plugin_dir(target: Path) -> tuple[bool, str]:
|
||||
stash_created, err = _autostash_dirty_tree(git_exe, target)
|
||||
if err:
|
||||
return False, err
|
||||
result = _run_plugin_git(git_exe, target, "pull", "--ff-only")
|
||||
origin = _run_plugin_git(git_exe, target, "remote", "get-url", "origin", timeout=15)
|
||||
result = _run_plugin_git(git_exe, target, "pull", "--ff-only", auth_url=origin.stdout.strip())
|
||||
if result.returncode != 0:
|
||||
err = _safe_git_error(result) or "git pull failed."
|
||||
if not stash_created:
|
||||
|
||||
@@ -230,10 +230,11 @@ def _looks_like_git_url(s: str) -> bool:
|
||||
def _git_clone(url: str, dest: Path) -> None:
|
||||
if _GITHUB_SHORTHAND_RE.match(url):
|
||||
url = f"https://{url.rstrip('/')}"
|
||||
from hermes_cli.git_credentials import with_git_auth
|
||||
try:
|
||||
subprocess.run(
|
||||
["git", "clone", "--depth", "1", url, str(dest)], check=True, capture_output=True,
|
||||
stdin=subprocess.DEVNULL, env=noninteractive_git_env(),
|
||||
stdin=subprocess.DEVNULL, env=with_git_auth(noninteractive_git_env(), url),
|
||||
)
|
||||
except FileNotFoundError as exc:
|
||||
raise DistributionError("git is required for git-URL installs") from exc
|
||||
|
||||
@@ -747,6 +747,8 @@ class TestGitInstallShaRef:
|
||||
|
||||
monkeypatch.setattr(mcp_catalog.subprocess, "run", fake_run)
|
||||
monkeypatch.setattr(mcp_catalog.shutil, "which", lambda x: "/usr/bin/git")
|
||||
from hermes_cli import git_credentials
|
||||
monkeypatch.setattr(git_credentials, "resolve_git_basic_auth", lambda url: None)
|
||||
|
||||
from hermes_cli.mcp_catalog import get_entry
|
||||
entry = get_entry("demo")
|
||||
|
||||
66
tests/hermes_cli/test_plugin_private_repo_auth.py
Normal file
66
tests/hermes_cli/test_plugin_private_repo_auth.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Private-repo plugin installs attach the user's stored HTTPS credential to git without persisting it."""
|
||||
|
||||
import base64
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli import git_credentials, plugins_cmd
|
||||
from hermes_cli._subprocess_compat import noninteractive_git_env
|
||||
|
||||
|
||||
def test_private_clone_authenticates_without_writing_credentials_to_checkout(tmp_path, monkeypatch):
|
||||
"""A clone from a remote that rejects anonymous access succeeds when a credential is resolvable,
|
||||
and the installed checkout carries no trace of it."""
|
||||
upstream = tmp_path / "upstream.git"
|
||||
subprocess.run(["git", "init", "-q", "--bare", str(upstream)], check=True)
|
||||
work = tmp_path / "work"
|
||||
subprocess.run(["git", "clone", "-q", str(upstream), str(work)], check=True)
|
||||
(work / "plugin.yaml").write_text("name: probe\ndescription: d\nversion: '1'\n")
|
||||
subprocess.run(["git", "-C", str(work), "-c", "user.name=t", "-c", "user.email=t@t", "add", "."], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "i"], check=True)
|
||||
subprocess.run(["git", "-C", str(work), "push", "-q", "origin", "HEAD"], check=True)
|
||||
|
||||
# Stand-in for a private remote: git is real, the URL is https, and the test asserts the auth
|
||||
# header reached git by having git echo its effective config for that origin.
|
||||
seen = {}
|
||||
real_run = subprocess.run
|
||||
|
||||
def spy_run(argv, *a, **kw):
|
||||
env = kw.get("env") or {}
|
||||
if "clone" in argv:
|
||||
headers = [env[f"GIT_CONFIG_VALUE_{i}"] for i in range(int(env["GIT_CONFIG_COUNT"]))
|
||||
if env[f"GIT_CONFIG_KEY_{i}"] == "http.https://git.example.test/.extraheader"]
|
||||
seen["headers"] = headers
|
||||
argv = [a_ if a_ != "https://git.example.test/acme/probe.git" else str(upstream) for a_ in argv]
|
||||
return real_run(argv, *a, **kw)
|
||||
|
||||
monkeypatch.setattr(plugins_cmd.subprocess, "run", spy_run)
|
||||
monkeypatch.setattr(git_credentials, "resolve_git_basic_auth", lambda url: ("alice", "s3cret"))
|
||||
|
||||
dest = tmp_path / "clone"
|
||||
plugins_cmd._clone_plugin_repo(dest, "https://git.example.test/acme/probe.git", None)
|
||||
|
||||
expected = base64.b64encode(b"alice:s3cret").decode()
|
||||
assert seen["headers"] == [f"Authorization: basic {expected}"]
|
||||
assert "s3cret" not in (dest / ".git" / "config").read_text()
|
||||
assert expected not in (dest / ".git" / "config").read_text()
|
||||
# Non-HTTPS URLs get no header; the hardened base env is otherwise untouched.
|
||||
base = noninteractive_git_env()
|
||||
assert git_credentials.with_git_auth(base, "git@github.com:acme/probe.git") == dict(base)
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell stub credential helper")
|
||||
def test_credential_fill_uses_stored_helper_and_never_prompts(tmp_path, monkeypatch):
|
||||
helper = tmp_path / "helper.sh"
|
||||
helper.write_text("#!/bin/sh\n[ \"$1\" = get ] && printf 'username=bob\\npassword=pw-from-helper\\n'\n")
|
||||
helper.chmod(0o755)
|
||||
gitconfig = tmp_path / "gitconfig"
|
||||
gitconfig.write_text(f'[credential "https://git.example.test"]\n\thelper = !{helper}\n')
|
||||
monkeypatch.setenv("GIT_CONFIG_GLOBAL", str(gitconfig))
|
||||
monkeypatch.setenv("GIT_ASKPASS", "/nonexistent/askpass-must-not-run")
|
||||
|
||||
assert git_credentials.resolve_git_basic_auth("https://git.example.test/acme/x.git") == ("bob", "pw-from-helper")
|
||||
# Unknown host: no helper answers → None quickly, no prompt attempt escaped.
|
||||
assert git_credentials.resolve_git_basic_auth("https://nothing.example.test/x.git") is None
|
||||
@@ -153,18 +153,18 @@ class TestResolveGitExecutable:
|
||||
return_value="/resolved/git",
|
||||
):
|
||||
with patch.object(pc.subprocess, "run") as run:
|
||||
# First call is `git status --porcelain` (clean tree),
|
||||
# second is the pull itself.
|
||||
# `git status --porcelain` (clean tree), `remote get-url origin`, then the pull.
|
||||
run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="", stderr=""),
|
||||
MagicMock(returncode=0, stdout="git@example.com:x.git\n", stderr=""),
|
||||
MagicMock(returncode=0, stdout="Already up to date\n", stderr=""),
|
||||
]
|
||||
ok, msg = pc._git_pull_plugin_dir(tmp_path)
|
||||
assert ok is True
|
||||
assert run.call_count == 2
|
||||
assert run.call_count == 3
|
||||
for call in run.call_args_list:
|
||||
assert call.args[0][0] == "/resolved/git"
|
||||
assert run.call_args_list[1].args[0][1:] == ["pull", "--ff-only"]
|
||||
assert run.call_args_list[2].args[0][1:] == ["pull", "--ff-only"]
|
||||
|
||||
def test_git_pull_clean_tree_never_stashes(self, tmp_path):
|
||||
import hermes_cli.plugins_cmd as pc
|
||||
@@ -174,6 +174,7 @@ class TestResolveGitExecutable:
|
||||
with patch.object(pc.subprocess, "run") as run:
|
||||
run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # status
|
||||
MagicMock(returncode=0, stdout="git@example.com:x.git\n", stderr=""), # remote get-url
|
||||
MagicMock(returncode=0, stdout="Updated\n", stderr=""), # pull
|
||||
]
|
||||
ok, msg = pc._git_pull_plugin_dir(tmp_path)
|
||||
@@ -414,12 +415,13 @@ class TestCmdUpdate:
|
||||
|
||||
mock_run.side_effect = [
|
||||
MagicMock(returncode=0, stdout="", stderr=""), # status: clean
|
||||
MagicMock(returncode=0, stdout="git@example.com:x.git", stderr=""), # remote get-url
|
||||
MagicMock(returncode=0, stdout="Updated", stderr=""), # pull
|
||||
]
|
||||
|
||||
cmd_update("test-plugin")
|
||||
|
||||
assert mock_run.call_count == 2
|
||||
assert mock_run.call_count == 3
|
||||
|
||||
@patch("hermes_cli.plugins_cmd._sanitize_plugin_name")
|
||||
@patch("hermes_cli.plugins_cmd._plugins_dir")
|
||||
|
||||
@@ -191,6 +191,23 @@ plugin; choose a new exact commit explicitly with
|
||||
profile-local install metadata contains no config values, environment values,
|
||||
secrets, or capability grants.
|
||||
|
||||
### Installing from a private repository
|
||||
|
||||
`hermes plugins install` clones non-interactively (it never prompts for a
|
||||
username or password), so a private repo needs a credential Hermes can find on
|
||||
its own. For an `https://` source it tries, in order:
|
||||
|
||||
1. `GITHUB_TOKEN` or `GH_TOKEN` from your `.env` (GitHub hosts only).
|
||||
2. The `gh` CLI's login (`gh auth login`), GitHub hosts only.
|
||||
3. Your git credential helper (`git credential fill`) for that host — works for
|
||||
GitLab, Bitbucket and self-hosted servers if a credential is already stored.
|
||||
|
||||
The credential is sent as a one-shot HTTP header for that install or update;
|
||||
it is never written into the plugin's `.git/config` or the install metadata.
|
||||
SSH sources (`git@host:owner/repo.git`) authenticate through your ssh-agent as
|
||||
before. The same resolution applies to `hermes plugins update`, catalog MCP
|
||||
installs from git, and profile distributions fetched from a git URL.
|
||||
|
||||
### What the allow-list does NOT gate
|
||||
|
||||
Several categories of plugin bypass `plugins.enabled` — they're part of Hermes' built-in surface and would break basic functionality if gated off by default:
|
||||
|
||||
Reference in New Issue
Block a user