feat(hermes-install): diagnose network failures and add optional Git mirror (#273)

- Detect git/network failure patterns (failed to connect, could not resolve host,
  unable to access, etc.) in install/update output and append a clear hint
  pointing users to the proxy or mirror settings instead of leaving them with
  raw multi-line git stderr.
- Add optional 'Hermes Install Mirror' setting (panelConfig.gitMirror): when set,
  install/upgrade injects GIT_CONFIG_COUNT/KEY_0/VALUE_0 to rewrite
  https://github.com/ via the mirror prefix at process scope only — the user's
  global ~/.gitconfig is never touched.
- Surface the new mirror field in Settings (works for both engines), with
  zh-CN/en/zh-TW copy and a hint explaining how it interacts with the install
  flow.
This commit is contained in:
晴天
2026-05-14 01:46:55 +08:00
parent e9cd2c6059
commit 7d4a423df0
4 changed files with 214 additions and 10 deletions

View File

@@ -168,6 +168,54 @@ function sanitizeHermesInstallOutput(text = '') {
.replaceAll('NousResearch/hermes-agent', 'hermes-agent')
}
// 读取 panel config (~/.openclaw/clawpanel.json) 中的 gitMirror 前缀。
// 为空/未设置 → 返回 '' 不启用镜像。
function gitMirrorPrefix() {
try {
const cfgPath = path.join(DEFAULT_OPENCLAW_DIR, 'clawpanel.json')
if (!fs.existsSync(cfgPath)) return ''
const raw = fs.readFileSync(cfgPath, 'utf8')
const cfg = JSON.parse(raw)
const v = String(cfg?.gitMirror || '').trim()
return v
} catch {
return ''
}
}
// 返回一个 env 添加包,含 GIT_CONFIG_COUNT/KEY/VALUE 临时重写。
// 未配置镜像 → 返回空对象。
function gitMirrorEnv() {
let mirror = gitMirrorPrefix()
if (!mirror) return {}
if (!mirror.endsWith('/')) mirror += '/'
return {
GIT_CONFIG_COUNT: '1',
GIT_CONFIG_KEY_0: `url.${mirror}https://github.com/.insteadOf`,
GIT_CONFIG_VALUE_0: 'https://github.com/',
}
}
// 判断输出是否命中 「网络无法访问」 类失败,命中返回建议文案。
function diagnoseHermesInstallError(text = '') {
const lower = String(text || '').toLowerCase()
const hits = [
'failed to connect to github.com',
'could not connect to server',
'failed to clone',
'unable to access',
'git operation failed',
'connection timed out',
'connection refused',
'network is unreachable',
'could not resolve host',
]
if (!hits.some(h => lower.includes(h))) return null
return '⚠ 检测到安装过程中无法访问外部 Git 服务。请任选一项重试:'
+ '\n 1) 在「设置 → 网络代理」配置可用代理后重试;'
+ '\n 2) 在「设置 → Hermes 安装镜像」填入可用的 Git 镜像前缀。'
}
let _hermesGwProcess = null
const __dev_dirname = path.dirname(fileURLToPath(import.meta.url))
@@ -6884,13 +6932,18 @@ const handlers = {
? ['pip', 'install', pkg]
: ['tool', 'install', '--force', pkg, '--python', '3.11', '--with', 'croniter']
const result = spawnSync(uv, installArgs, {
env: { ...process.env, PATH: hermesEnhancedPath(), GIT_TERMINAL_PROMPT: '0' },
env: { ...process.env, PATH: hermesEnhancedPath(), GIT_TERMINAL_PROMPT: '0', ...gitMirrorEnv() },
timeout: 600000,
windowsHide: true,
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'pipe'],
})
if (result.status !== 0) throw new Error(`安装失败: ${sanitizeHermesInstallOutput((result.stderr || '').trim())}`)
if (result.status !== 0) {
const cleaned = sanitizeHermesInstallOutput((result.stderr || '').trim())
const hint = diagnoseHermesInstallError(cleaned)
if (hint) throw new Error(`安装失败: ${cleaned}\n\n${hint}`)
throw new Error(`安装失败: ${cleaned}`)
}
// 3. 验证
const ver = runHermesSilent('hermes', ['version'])
if (ver.ok) return ver.stdout
@@ -8243,10 +8296,15 @@ const handlers = {
const uv = fs.existsSync(uvPath) ? uvPath : 'uv'
const pkg = 'hermes-agent[web] @ git+https://github.com/NousResearch/hermes-agent.git'
const result = spawnSync(uv, ['tool', 'install', '--reinstall', pkg, '--python', '3.11', '--with', 'croniter'], {
env: { ...process.env, PATH: hermesEnhancedPath(), GIT_TERMINAL_PROMPT: '0' },
env: { ...process.env, PATH: hermesEnhancedPath(), GIT_TERMINAL_PROMPT: '0', ...gitMirrorEnv() },
timeout: 600000, windowsHide: true, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'],
})
if (result.status !== 0) throw new Error(`升级失败: ${sanitizeHermesInstallOutput((result.stderr || '').trim())}`)
if (result.status !== 0) {
const cleaned = sanitizeHermesInstallOutput((result.stderr || '').trim())
const hint = diagnoseHermesInstallError(cleaned)
if (hint) throw new Error(`升级失败: ${cleaned}\n\n${hint}`)
throw new Error(`升级失败: ${cleaned}`)
}
return '升级完成'
},

View File

@@ -1393,6 +1393,61 @@ fn sanitize_hermes_install_output(text: &str) -> String {
out.replace("NousResearch/hermes-agent", "hermes-agent")
}
/// 从 panelConfig.gitMirror 读取镜像前缀(如 "https://ghproxy.com/")。
/// 为空/未设置 → 不启用镜像。
fn git_mirror_prefix() -> Option<String> {
super::read_panel_config_value()
.and_then(|v| v.get("gitMirror")?.as_str().map(String::from))
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
}
/// 给 tokio::process::Command 注入 git insteadOf 重写 env
/// 进程级别(不污染用户全局 ~/.gitconfig。仅当配置了镜像时会动作。
fn apply_git_mirror_env(cmd: &mut tokio::process::Command) {
let Some(mirror) = git_mirror_prefix() else {
return;
};
let mirror = if mirror.ends_with('/') {
mirror
} else {
format!("{mirror}/")
};
// git 读取 GIT_CONFIG_COUNT 个临时配置项,仅影响当前进程
cmd.env("GIT_CONFIG_COUNT", "1");
cmd.env(
"GIT_CONFIG_KEY_0",
format!("url.{mirror}https://github.com/.insteadOf"),
);
cmd.env("GIT_CONFIG_VALUE_0", "https://github.com/");
}
/// 诊断 Hermes 安装/升级输出是否命中「网络无法访问」类失败,
/// 命中返回建议文案(含「可在设置页启用 Git 镜像」提示)。
fn diagnose_install_network_error(text: &str) -> Option<String> {
let lower = text.to_lowercase();
let hits = [
"failed to connect to github.com",
"could not connect to server",
"failed to clone",
"unable to access",
"git operation failed",
"connection timed out",
"connection refused",
"network is unreachable",
"could not resolve host",
];
if !hits.iter().any(|h| lower.contains(h)) {
return None;
}
Some(
"⚠ 检测到安装过程中无法访问外部 Git 服务。请任选一项重试:\
\n 1) 在「设置 → 网络代理」配置可用代理后重试;\
\n 2) 在「设置 → Hermes 安装镜像」填入可用的 Git 镜像前缀。"
.to_string(),
)
}
/// 通过 uv tool install 安装 Hermes Agent
async fn install_via_uv_tool(
app: &tauri::AppHandle,
@@ -1427,6 +1482,8 @@ async fn install_via_uv_tool(
cmd.env("PATH", hermes_enhanced_path());
// uv 需要 git 来克隆仓库
cmd.env("GIT_TERMINAL_PROMPT", "0");
// 用户配置了 Git 镜像(如 ghproxy→ 进程级注入 insteadOf 重写
apply_git_mirror_env(&mut cmd);
#[cfg(target_os = "windows")]
cmd.creation_flags(CREATE_NO_WINDOW);
@@ -1469,10 +1526,21 @@ async fn install_via_uv_tool(
let _ = update_cmd.output().await;
Ok(())
} else {
let cleaned = sanitize_hermes_install_output(stderr.trim());
// 命中 git/network 失败 → 在日志流尾部追加诊断 + 给最终错误消息加上提示
if let Some(hint) = diagnose_install_network_error(&cleaned) {
let _ = app.emit("hermes-install-log", &hint);
return Err(format!(
"安装失败 (exit {}): {}\n\n{}",
output.status.code().unwrap_or(-1),
cleaned,
hint
));
}
Err(format!(
"安装失败 (exit {}): {}",
output.status.code().unwrap_or(-1),
sanitize_hermes_install_output(stderr.trim())
cleaned
))
}
}
@@ -1529,6 +1597,7 @@ async fn install_via_uv_pip(
if let Some(mirror) = pypi_mirror_url() {
pip_cmd.args(["--index-url", &mirror]);
}
apply_git_mirror_env(&mut pip_cmd);
super::apply_proxy_env_tokio(&mut pip_cmd);
#[cfg(target_os = "windows")]
pip_cmd.creation_flags(CREATE_NO_WINDOW);
@@ -2805,6 +2874,7 @@ pub async fn update_hermes(app: tauri::AppHandle) -> Result<String, String> {
if let Some(mirror) = pypi_mirror_url() {
cmd.args(["--index-url", &mirror]);
}
apply_git_mirror_env(&mut cmd);
super::apply_proxy_env_tokio(&mut cmd);
cmd.env("PATH", hermes_enhanced_path());
#[cfg(target_os = "windows")]
@@ -2828,10 +2898,12 @@ pub async fn update_hermes(app: tauri::AppHandle) -> Result<String, String> {
let _ = app.emit("hermes-install-progress", 100u32);
Ok("升级完成".into())
} else {
Err(format!(
"升级失败: {}",
sanitize_hermes_install_output(stderr.trim())
))
let cleaned = sanitize_hermes_install_output(stderr.trim());
if let Some(hint) = diagnose_install_network_error(&cleaned) {
let _ = app.emit("hermes-install-log", &hint);
return Err(format!("升级失败: {}\n\n{}", cleaned, hint));
}
Err(format!("升级失败: {}", cleaned))
}
}

View File

@@ -76,6 +76,25 @@ export default {
proxyCleared: _('网络代理已关闭', 'Network proxy disabled', '網路代理已關閉', 'ネットワークプロキシ無効化', '네트워크 프록시 비활성화됨'),
modelProxyOn: _('模型请求将走代理', 'Model requests will use proxy', '模型請求將走代理', 'モデルリクエストはプロキシを使用します', '모델 요청이 프록시를 사용합니다'),
modelProxyOff: _('模型请求已关闭代理', 'Model requests proxy disabled', '模型請求已關閉代理', 'モデルリクエストプロキシ無効化', '모델 요청 프록시 비활성화됨'),
hermesMirror: _(
'Hermes 安装镜像',
'Hermes Install Mirror',
'Hermes 安裝鏡像',
'Hermes インストールミラー',
'Hermes 설치 미러'
),
hermesMirrorHint: _(
'当 Hermes Agent 安装/升级因 GitHub 网络不通失败时,在这里填入可用的 Git 镜像前缀(如 https://ghproxy.com/)。仅作为环境变量临时生效,不会修改全局 git 配置。留空 = 直接连 GitHub。',
'When installing/upgrading Hermes Agent fails because GitHub is unreachable, set a Git mirror prefix here (e.g. https://ghproxy.com/). Applied only as a per-process environment override; your global git config is untouched. Leave empty to connect GitHub directly.',
'當 Hermes Agent 安裝/升級因 GitHub 網路不通失敗時,在這裡填入可用的 Git 鏡像前綴(如 https://ghproxy.com/)。僅作為環境變數臨時生效,不會修改全局 git 配置。留空 = 直接連 GitHub。'
),
hermesMirrorPlaceholder: _(
'https://ghproxy.com/ (留空 = 不使用镜像)',
'https://ghproxy.com/ (leave empty to disable)',
'https://ghproxy.com/ (留空 = 不使用鏡像)'
),
hermesMirrorSaved: _('Hermes 安装镜像已保存', 'Hermes install mirror saved', 'Hermes 安裝鏡像已儲存'),
hermesMirrorCleared: _('已关闭 Hermes 安装镜像', 'Hermes install mirror disabled', '已關閉 Hermes 安裝鏡像'),
gitPath: _('Git 可执行文件路径', 'Git Executable Path', 'Git 可執行檔路徑', 'Git 実行ファイルパス', 'Git 실행 파일 경로'),
gitPathHint: _('自定义 Git 可执行文件路径。留空则自动从系统 PATH 中查找。当系统找不到 Git 时,可在此手动指定完整路径。', 'Custom Git executable path. Leave empty to auto-detect from system PATH. Specify the full path here if the system cannot find Git.', '自定義 Git 可執行檔路徑。留空則自動從系統 PATH 中尋找。當系統找不到 Git 時,可在此手動指定完整路徑。', 'カスタム Git 実行ファイルパス。空欄にするとシステム PATH から自動検出します。システムが Git を見つけられない場合はここにフルパスを指定してください。'),
gitPathPlaceholder: _('留空自动检测,例如 C:\\Program Files\\Git\\cmd\\git.exe', 'Leave empty for auto-detect, e.g. C:\\Program Files\\Git\\cmd\\git.exe', '留空自動檢測,例如 /usr/local/bin/git', '空欄で自動検出、例: C:\\Program Files\\Git\\cmd\\git.exe'),

View File

@@ -113,6 +113,11 @@ export async function render() {
<div id="cli-binding-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>`}
<div class="config-section" id="hermes-mirror-section">
<div class="config-section-title">${t('settings.hermesMirror')}</div>
<div id="hermes-mirror-bar"><div class="stat-card loading-placeholder" style="height:48px"></div></div>
</div>
<div class="config-section" id="language-section">
<div class="config-section-title">${t('settings.language')}</div>
<div id="language-bar"></div>
@@ -132,7 +137,7 @@ export async function render() {
async function loadAll(page) {
const isHermes = getActiveEngineId() === 'hermes'
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page)]
const tasks = [loadProxyConfig(page), loadModelProxyConfig(page), loadHermesMirror(page)]
if (!isHermes) {
tasks.push(loadOpenclawDir(page), loadOpenclawSearchPaths(page), loadDockerDefaults(page), loadGitPath(page), loadCliBinding(page), loadRegistry(page))
}
@@ -477,6 +482,12 @@ function bindEvents(page) {
case 'reset-git-path':
await handleResetGitPath(page)
break
case 'save-hermes-mirror':
await handleSaveHermesMirror(page)
break
case 'reset-hermes-mirror':
await handleResetHermesMirror(page)
break
case 'scan-git-paths':
await handleScanGitPaths(page)
break
@@ -659,6 +670,50 @@ async function handleResetGitPath(page) {
await loadGitPath(page)
}
// ===== Hermes 安装镜像 =====
async function loadHermesMirror(page) {
const bar = page.querySelector('#hermes-mirror-bar')
if (!bar) return
try {
const cfg = await api.readPanelConfig()
const value = cfg?.gitMirror || ''
bar.innerHTML = `
<div class="stat-card" style="padding:16px">
<p style="font-size:var(--font-size-xs);color:var(--text-tertiary);margin-bottom:12px;line-height:1.5">${t('settings.hermesMirrorHint')}</p>
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
<input class="input" data-name="hermes-mirror" value="${escapeHtml(value)}" placeholder="${t('settings.hermesMirrorPlaceholder')}" style="flex:1;min-width:240px">
<button class="btn btn-primary btn-sm" data-action="save-hermes-mirror">${t('common.save')}</button>
${value ? `<button class="btn btn-secondary btn-sm" data-action="reset-hermes-mirror">${t('settings.resetDefault')}</button>` : ''}
</div>
</div>`
} catch (e) {
bar.innerHTML = `<div class="stat-card" style="padding:16px;color:var(--error)">${e}</div>`
}
}
async function handleSaveHermesMirror(page) {
const input = page.querySelector('[data-name="hermes-mirror"]')
const value = (input?.value || '').trim()
const cfg = await api.readPanelConfig()
if (value) {
cfg.gitMirror = value
} else {
delete cfg.gitMirror
}
await api.writePanelConfig(cfg)
toast(value ? t('settings.hermesMirrorSaved') : t('settings.hermesMirrorCleared'), 'success')
await loadHermesMirror(page)
}
async function handleResetHermesMirror(page) {
const cfg = await api.readPanelConfig()
delete cfg.gitMirror
await api.writePanelConfig(cfg)
toast(t('settings.hermesMirrorCleared'), 'success')
await loadHermesMirror(page)
}
// ===== CLI 绑定 =====
async function loadCliBinding(page) {