fix(update): avoid SSH auth for passive official checks

This commit is contained in:
helix4u 2026-06-10 13:59:55 -06:00 committed by kshitijk4poor
parent 4829f8d2c5
commit cedd9b6d47
3 changed files with 159 additions and 2 deletions

View file

@ -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 {

View file

@ -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"],

View file

@ -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):