From cedd9b6d475bad9e0917c3ceb861377ae2735959 Mon Sep 17 00:00:00 2001 From: helix4u <4317663+helix4u@users.noreply.github.com> Date: Wed, 10 Jun 2026 13:59:55 -0600 Subject: [PATCH] fix(update): avoid SSH auth for passive official checks --- apps/desktop/electron/main.cjs | 74 ++++++++++++++++++++++++++- hermes_cli/banner.py | 53 +++++++++++++++++++ tests/hermes_cli/test_update_check.py | 34 +++++++++++- 3 files changed, 159 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron/main.cjs b/apps/desktop/electron/main.cjs index 5e128421a83..dcd30ed5432 100644 --- a/apps/desktop/electron/main.cjs +++ b/apps/desktop/electron/main.cjs @@ -278,6 +278,8 @@ const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/ // tracks main. User can also override at runtime via // hermesDesktop.updates.setBranch(). const DEFAULT_UPDATE_BRANCH = 'main' +const OFFICIAL_REPO_HTTPS_URL = 'https://github.com/NousResearch/hermes-agent.git' +const OFFICIAL_REPO_CANONICAL = 'github.com/nousresearch/hermes-agent' // desktop.log lives under HERMES_HOME/logs/ so it sits next to agent.log, // errors.log, gateway.log produced by hermes_logging.setup_logging — one log // directory per user, regardless of which UI surface produced the line. @@ -1312,6 +1314,40 @@ function runGit(args, options = {}) { const firstLine = text => (text || '').split('\n').find(Boolean) || '' +function canonicalGitHubRemote(url) { + if (!url) return '' + let value = String(url).trim() + if (value.startsWith('git@github.com:')) { + value = `github.com/${value.slice('git@github.com:'.length)}` + } else if (value.startsWith('ssh://git@github.com/')) { + value = `github.com/${value.slice('ssh://git@github.com/'.length)}` + } else { + try { + const parsed = new URL(value) + if (parsed.hostname && parsed.pathname) value = `${parsed.hostname}${parsed.pathname}` + } catch { + // Leave non-URL forms unchanged. + } + } + value = value.trim().replace(/\/+$/, '') + if (value.endsWith('.git')) value = value.slice(0, -4) + return value.toLowerCase() +} + +function isSshRemote(url) { + const value = String(url || '').trim().toLowerCase() + return value.startsWith('git@') || value.startsWith('ssh://') +} + +function isOfficialSshRemote(url) { + return isSshRemote(url) && canonicalGitHubRemote(url) === OFFICIAL_REPO_CANONICAL +} + +async function getOriginUrl(updateRoot) { + const origin = await runGit(['remote', 'get-url', 'origin'], { cwd: updateRoot }) + return origin.code === 0 ? origin.stdout.trim() : '' +} + function emitUpdateProgress(payload) { const merged = { stage: 'idle', message: '', percent: null, error: null, ...payload, at: Date.now() } rememberLog(`[updates] ${merged.stage}: ${merged.message || merged.error || ''}`) @@ -1331,7 +1367,9 @@ async function resolveHealedBranch(updateRoot, branch) { return branch || 'main' } - const probe = await runGit(['ls-remote', '--exit-code', '--heads', 'origin', branch], { cwd: updateRoot }) + const originUrl = await getOriginUrl(updateRoot) + const remote = isOfficialSshRemote(originUrl) ? OFFICIAL_REPO_HTTPS_URL : 'origin' + const probe = await runGit(['ls-remote', '--exit-code', '--heads', remote, branch], { cwd: updateRoot }) if (probe.code !== 2) { return branch } @@ -1359,6 +1397,40 @@ async function checkUpdates() { } branch = await resolveHealedBranch(updateRoot, branch) + const originUrl = await getOriginUrl(updateRoot) + if (isOfficialSshRemote(originUrl)) { + const git = args => runGit(args, { cwd: updateRoot }).then(r => r.stdout.trim()) + const [currentSha, target, dirtyStr, currentBranch] = await Promise.all([ + git(['rev-parse', 'HEAD']), + runGit(['ls-remote', OFFICIAL_REPO_HTTPS_URL, `refs/heads/${branch}`], { cwd: updateRoot }), + git(['status', '--porcelain']), + git(['rev-parse', '--abbrev-ref', 'HEAD']) + ]) + const targetSha = firstLine(target.stdout).split(/\s+/)[0] || '' + if (target.code !== 0 || !targetSha) { + return { + supported: true, + branch, + error: 'fetch-failed', + message: firstLine(target.stderr) || 'git ls-remote failed.', + hermesRoot: updateRoot, + fetchedAt: Date.now() + } + } + return { + supported: true, + branch, + currentBranch, + behind: currentSha && currentSha === targetSha ? 0 : 1, + currentSha, + targetSha, + commits: [], + dirty: dirtyStr.length > 0, + hermesRoot: updateRoot, + fetchedAt: Date.now() + } + } + const fetched = await runGit(['fetch', '--quiet', 'origin', branch], { cwd: updateRoot }) if (fetched.code !== 0) { return { diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index 1955b009df2..af0bdd5feef 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -11,6 +11,7 @@ import subprocess import threading import time from pathlib import Path +from urllib.parse import urlparse from hermes_constants import get_hermes_home from typing import TYPE_CHECKING, Dict, List, Optional @@ -121,6 +122,53 @@ _UPDATE_CHECK_CACHE_SECONDS = 6 * 3600 UPDATE_AVAILABLE_NO_COUNT = -1 _UPSTREAM_REPO_URL = "https://github.com/NousResearch/hermes-agent.git" +_OFFICIAL_REPO_CANONICAL = "github.com/nousresearch/hermes-agent" + + +def _canonical_github_remote(url: str | None) -> str: + """Return ``host/owner/repo`` for common GitHub remote URL forms.""" + if not url: + return "" + value = url.strip() + if value.startswith("git@github.com:"): + value = "github.com/" + value[len("git@github.com:"):] + elif value.startswith("ssh://git@github.com/"): + value = "github.com/" + value[len("ssh://git@github.com/"):] + else: + parsed = urlparse(value) + if parsed.netloc and parsed.path: + value = f"{parsed.netloc}{parsed.path}" + value = value.strip().rstrip("/") + if value.endswith(".git"): + value = value[:-4] + return value.lower() + + +def _is_ssh_remote(url: str | None) -> bool: + if not url: + return False + value = url.strip().lower() + return value.startswith("git@") or value.startswith("ssh://") + + +def _is_official_ssh_remote(url: str | None) -> bool: + return _is_ssh_remote(url) and _canonical_github_remote(url) == _OFFICIAL_REPO_CANONICAL + + +def _git_stdout(args: list[str], *, cwd: Path, timeout: int = 5) -> Optional[str]: + try: + result = subprocess.run( + ["git", *args], + capture_output=True, + text=True, + timeout=timeout, + cwd=str(cwd), + ) + except Exception: + return None + if result.returncode != 0: + return None + return (result.stdout or "").strip() def _check_via_rev(local_rev: str) -> Optional[int]: @@ -146,6 +194,11 @@ def _check_via_rev(local_rev: str) -> Optional[int]: def _check_via_local_git(repo_dir: Path) -> Optional[int]: """Count commits behind origin/main in a local checkout.""" + origin_url = _git_stdout(["remote", "get-url", "origin"], cwd=repo_dir) + if _is_official_ssh_remote(origin_url): + head_rev = _git_stdout(["rev-parse", "HEAD"], cwd=repo_dir) + return _check_via_rev(head_rev) if head_rev else None + try: subprocess.run( ["git", "fetch", "origin", "--quiet"], diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index 47c018cbbdb..5c590bff15c 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -93,7 +93,39 @@ def test_check_for_updates_expired_cache(tmp_path, monkeypatch): result = check_for_updates() assert result == 5 - assert mock_run.call_count == 2 # git fetch + git rev-list + assert mock_run.call_count == 3 # origin probe + git fetch + git rev-list + + +def test_check_for_updates_official_ssh_origin_uses_https_probe(tmp_path): + """Passive update checks must not trigger SSH auth for official installs.""" + import hermes_cli.banner as banner + + repo_dir = tmp_path / "hermes-agent" + repo_dir.mkdir() + (repo_dir / ".git").mkdir() + + calls = [] + + def fake_run(cmd, **kwargs): + calls.append(cmd) + if cmd == ["git", "remote", "get-url", "origin"]: + return MagicMock(returncode=0, stdout="git@github.com:NousResearch/hermes-agent.git\n") + if cmd == ["git", "rev-parse", "HEAD"]: + return MagicMock(returncode=0, stdout="local-sha\n") + if cmd == [ + "git", + "ls-remote", + "https://github.com/NousResearch/hermes-agent.git", + "refs/heads/main", + ]: + return MagicMock(returncode=0, stdout="upstream-sha\trefs/heads/main\n") + raise AssertionError(f"unexpected git command: {cmd!r}") + + with patch("hermes_cli.banner.subprocess.run", side_effect=fake_run): + result = banner._check_via_local_git(repo_dir) + + assert result == banner.UPDATE_AVAILABLE_NO_COUNT + assert ["git", "fetch", "origin", "--quiet"] not in calls def test_check_for_updates_no_git_dir(tmp_path, monkeypatch):