5 Commits

Author SHA1 Message Date
teknium1
5c44d5df9c fix(git-auth): a rejected GITHUB_TOKEN yields to the gh CLI login instead of failing the clone (#115257)
`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).
2026-09-20 10:44:52 -07:00
teknium1
7d0c1f2050 fix(plugins): drop inherited askpass from the anonymous git attempt so a 401 is classifiable
noninteractive_git_env() leaves GIT_ASKPASS/SSH_ASKPASS alone, and an env
askpass beats the injected core.askPass='' config. Under a VS Code
terminal or ksshaskpass the anonymous first attempt therefore handed the
remote's 401 to a GUI helper nobody answers: the run hit its timeout
(TimeoutExpired, no "could not read Username"), the refusal was never
classified and the stored credential was never attached — a regression
for private-repo installs that worked on main where the credential was
pre-attached.

Pop both askpass variables from the anonymous attempt's env, exactly as
_credential_fill already does, so the refusal fails fast and the
credential fallback fires. Probe (local 401 + WWW-Authenticate: Basic
server, GIT_ASKPASS=sleeping script, timeout=3): before TimeoutExpired at
3.0s with no second attempt; after rc 128 "could not read Username" in
0.5s and the fallback runs. Test is RED on the previous head
(TimeoutExpired) and GREEN here.
2026-09-18 10:01:01 -07:00
teknium1
9b55e7e6ac fix(plugins): attach stored git credentials only after an anonymous refusal, on every network verb
Widen the anonymous-first decision from #114545 to the whole class. The
clone-only wrapper (_clone_with_auth_fallback) folds into one runner,
git_credentials.run_git_with_credential_fallback, used by every site that
attached a stored credential unconditionally:

  - plugins_cmd._run_plugin_git -> clone, the pinned --ref fetch in
    _checkout_exact_revision, and the pull in _git_pull_plugin_dir
    (`hermes plugins update`), which the thread on #114526 showed still
    failing with "could not read Username ... terminal prompts disabled"
    after the clone succeeded anonymously
  - mcp_catalog._do_git_install (catalog MCP git installs)
  - profile_distribution._git_clone (profile distributions from a git URL)

Why the runner and not per-caller guards: the decision belongs to the
credential layer once; a rejected credential sent pre-emptively is what
turns a working anonymous clone into a 401 that git can only answer with
the prompt noninteractive_git_env disables. The credential-required
classifier also matches git's "returned error: 401" spelling (the picked
version needed a trailing space that git never prints) and bytes output.

Side effect: `gh auth token` is now shelled out only after a refusal, so
public installs no longer pay that subprocess at all.

Tests: the sibling verbs (fetch, pull) x (public, refused) and the negative
that a non-credential failure never resolves a credential; the SSH no-op
case from the pick is already asserted by the private-remote test. Docs:
website/docs/user-guide/features/plugins.md.

Fixes #114526
Co-authored-by: holny <holny@users.noreply.github.com>
2026-09-18 10:01:01 -07:00
Teknium
d783c312a7 fix(plugins): run gh auth token under the noninteractive git env
The MCP-catalog noninteractive contract test asserts every subprocess spawned during a git
install carries GIT_TERMINAL_PROMPT=0 and a closed stdin; the gh token probe was spawned with
the inherited env. Use the same hardened env (plus GH_PROMPT_DISABLED) so gh cannot open a
browser/device flow either, and let the contract accept a stdin fed by input= (credential fill
writes its request and closes).
2026-09-10 01:00:19 -07:00
Teknium
9886f6e53b 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.
2026-09-10 01:00:19 -07:00