diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 94f31d4..a85faed 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -6692,6 +6692,11 @@ const handlers = { const enhanced = hermesEnhancedPath() const port = hermesGatewayPort() if (action === 'start') { + // Guardian: ensure platforms.api_server.enabled:true before start. + // Mirrors Rust's ensure_api_server_enabled (see hermes.rs). + try { this._hermesEnsureApiServerEnabled() } catch (e) { + console.warn('[hermes guardian] patch failed:', e.message || e) + } // 检测是否已运行 const alive = await _tcpProbe('127.0.0.1', port, 300) if (alive) return 'Gateway 已在运行' @@ -6845,6 +6850,98 @@ const handlers = { return [] }, + // ----------------------------------------------------------------------- + // api_server guardian (Step 5) — mirror of Rust's config_has_api_server_enabled + // + patch_yaml_ensure_api_server + ensure_api_server_enabled. Called before + // every `hermes gateway run` so that an upgrade / manual edit that drops + // `platforms.api_server.enabled: true` is auto-healed. + // ----------------------------------------------------------------------- + _hermesConfigHasApiServerEnabled(raw) { + let inPlatforms = false + let inApiServer = false + for (const origLine of raw.split('\n')) { + const hash = origLine.indexOf('#') + const line = hash >= 0 ? origLine.slice(0, hash) : origLine + const trimmed = line.replace(/\s+$/, '') + if (!trimmed) continue + const indent = trimmed.length - trimmed.trimStart().length + if (indent === 0) { + inPlatforms = trimmed.trimStart().startsWith('platforms:') + inApiServer = false + continue + } + if (!inPlatforms) continue + if (indent <= 2) { + inApiServer = trimmed.trimStart().startsWith('api_server:') + continue + } + if (!inApiServer) continue + const t = trimmed.trimStart() + if (t.startsWith('enabled:')) { + const v = t.slice(8).trim().replace(/^['"]|['"]$/g, '').toLowerCase() + return ['true', 'yes', 'on', '1'].includes(v) + } + } + return false + }, + + _hermesPatchYamlEnsureApiServer(raw) { + if (this._hermesConfigHasApiServerEnabled(raw)) return raw + const lines = raw.split('\n') + const out = [] + let platformsFound = false + let i = 0 + while (i < lines.length) { + const line = lines[i] + const trimmed = line.replace(/\s+$/, '') + const indent = trimmed.length - trimmed.trimStart().length + if (indent === 0 && trimmed.trimStart().startsWith('platforms:')) { + out.push(line) + platformsFound = true + i++ + const accumulated = [] + let skipping = false + while (i < lines.length) { + const l = lines[i] + const t = l.replace(/\s+$/, '') + const ind = t.length - t.trimStart().length + if (ind === 0 && t !== '') break + if (ind <= 2) skipping = t.trimStart().startsWith('api_server:') + if (!skipping) accumulated.push(l) + i++ + } + out.push(' api_server:') + out.push(' enabled: true') + out.push(...accumulated) + continue + } + out.push(line) + i++ + } + if (!platformsFound) { + if (out.length && out[out.length - 1] !== '') out.push('') + out.push('platforms:') + out.push(' api_server:') + out.push(' enabled: true') + } + let content = out.join('\n') + if (!content.endsWith('\n')) content += '\n' + return content + }, + + _hermesEnsureApiServerEnabled() { + const configPath = path.join(hermesHome(), 'config.yaml') + if (!fs.existsSync(configPath)) return + const raw = fs.readFileSync(configPath, 'utf8') + if (this._hermesConfigHasApiServerEnabled(raw)) return + const ts = Math.floor(Date.now() / 1000) + const backupPath = configPath + `.bak-${ts}` + try { fs.writeFileSync(backupPath, raw) } catch {} + const patched = this._hermesPatchYamlEnsureApiServer(raw) + fs.writeFileSync(configPath, patched) + console.warn(`[hermes guardian] patched config.yaml (api_server.enabled). Backup: ${backupPath}`) + }, + // ========================================================================= // .env editor (Step 4) — Web-mode implementations mirroring Rust behavior. // The managed-key list is duplicated here since Rust's hermes_providers is diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 7495ee4..ee3eec7 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -1821,6 +1821,11 @@ pub async fn hermes_gateway_action( let enhanced = hermes_enhanced_path(); match action.as_str() { "start" => { + // Guardian: ensure platforms.api_server.enabled:true is present + // before every start. Auto-heal if missing (with a .bak backup). + // See `ensure_api_server_enabled` for rationale. + ensure_api_server_enabled(&app)?; + #[cfg(target_os = "windows")] { let home = hermes_home(); @@ -3139,6 +3144,185 @@ pub async fn hermes_memory_write( Ok("ok".into()) } +// ============================================================================ +// api_server guardian (Step 5 / G7) +// +// ClawPanel's Hermes integration requires `platforms.api_server.enabled: true` +// in ~/.hermes/config.yaml so that `hermes gateway run` exposes the +// /v1/runs endpoint we depend on. The setting is written once by +// `configure_hermes`, but several real-world scenarios can remove it: +// * User upgrades Hermes and the new default config.yaml is merged +// without the api_server platform entry. +// * User manually edits config.yaml (via Hermes CLI or text editor). +// * Migration scripts accidentally drop the section. +// +// Rather than silently failing at Gateway start time with an opaque +// "endpoint not found" error, this guardian checks before every start and +// auto-heals the config. A timestamped backup (config.yaml.bak-) +// is written before any mutation so users can always roll back. +// ============================================================================ + +/// Scan a YAML string for `platforms.api_server.enabled: true` and return +/// true only when that exact path exists with a truthy value. +fn config_has_api_server_enabled(raw: &str) -> bool { + let mut in_platforms = false; + let mut in_api_server = false; + for line in raw.lines() { + // Strip comments (crude, but matches the simple YAML we write). + let line = match line.find('#') { + Some(i) => &line[..i], + None => line, + }; + let trimmed = line.trim_end(); + if trimmed.is_empty() { + continue; + } + let indent = trimmed.len() - trimmed.trim_start().len(); + + if indent == 0 { + in_platforms = trimmed.trim_start().starts_with("platforms:"); + in_api_server = false; + continue; + } + if !in_platforms { + continue; + } + // Inside platforms: + if indent <= 2 { + in_api_server = trimmed.trim_start().starts_with("api_server:"); + continue; + } + if !in_api_server { + continue; + } + // Inside platforms.api_server: + let t = trimmed.trim_start(); + if let Some(rest) = t.strip_prefix("enabled:") { + let v = rest.trim().trim_matches(|c: char| c == '"' || c == '\''); + return matches!(v.to_ascii_lowercase().as_str(), "true" | "yes" | "on" | "1"); + } + } + false +} + +/// Produce a patched YAML that guarantees +/// `platforms.api_server.enabled: true` is present, preserving everything +/// else verbatim. If the config already has the setting (as `true`) this +/// returns the original text unchanged. +fn patch_yaml_ensure_api_server(raw: &str) -> String { + if config_has_api_server_enabled(raw) { + return raw.to_string(); + } + + // Strategy: + // * If `platforms:` exists, inject / replace api_server subtree under it. + // * Otherwise append a new top-level `platforms:` block at EOF. + let lines: Vec<&str> = raw.lines().collect(); + let mut out: Vec = Vec::with_capacity(lines.len() + 4); + let mut platforms_found = false; + let mut i = 0; + while i < lines.len() { + let line = lines[i]; + let trimmed = line.trim_end(); + let indent = trimmed.len() - trimmed.trim_start().len(); + + if indent == 0 && trimmed.trim_start().starts_with("platforms:") { + // Copy the platforms: header + out.push(line.to_string()); + platforms_found = true; + i += 1; + // Accumulate children and drop the existing api_server subtree + // (we'll rewrite it at the top of the block). Keep siblings. + let mut accumulated_children: Vec = Vec::new(); + let mut skipping_api_server = false; + while i < lines.len() { + let l = lines[i]; + let t = l.trim_end(); + let ind = t.len() - t.trim_start().len(); + if ind == 0 && !t.is_empty() { + break; // leaving platforms block + } + if ind <= 2 { + skipping_api_server = t.trim_start().starts_with("api_server:"); + } + if !skipping_api_server { + accumulated_children.push(l.to_string()); + } + i += 1; + } + // Inject a fresh api_server entry at the top of platforms: + out.push(" api_server:".into()); + out.push(" enabled: true".into()); + out.extend(accumulated_children); + continue; + } + out.push(line.to_string()); + i += 1; + } + + if !platforms_found { + if let Some(last) = out.last() { + if !last.is_empty() { + out.push(String::new()); + } + } + out.push("platforms:".into()); + out.push(" api_server:".into()); + out.push(" enabled: true".into()); + } + + let mut content = out.join("\n"); + if !content.ends_with('\n') { + content.push('\n'); + } + content +} + +/// Guardian called from `hermes_gateway_action` on every `start` request. +/// Returns Ok(()) when the config is healthy (either it was already correct +/// or the patch succeeded). Emits `hermes-config-patched` on auto-heal so +/// the frontend can display a transparent toast. +fn ensure_api_server_enabled(app: &tauri::AppHandle) -> Result<(), String> { + let config_path = hermes_home().join("config.yaml"); + if !config_path.exists() { + // Nothing to guard — configure_hermes will create a compliant file + // on first run. Don't auto-create here; that's outside the guard's + // responsibility. + return Ok(()); + } + let raw = std::fs::read_to_string(&config_path) + .map_err(|e| format!("Failed to read config.yaml: {e}"))?; + if config_has_api_server_enabled(&raw) { + return Ok(()); + } + + // Back up with a timestamped filename so we never overwrite an earlier + // .bak (rapid re-starts would lose history otherwise). + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + let backup_path = config_path.with_extension(format!("yaml.bak-{ts}")); + let _ = std::fs::write(&backup_path, &raw); + + let patched = patch_yaml_ensure_api_server(&raw); + std::fs::write(&config_path, &patched) + .map_err(|e| format!("Failed to write config.yaml: {e}"))?; + + // Inform the frontend so it can surface a toast. Failure to emit is + // non-fatal — the patch itself already succeeded. + use tauri::Emitter; + let _ = app.emit( + "hermes-config-patched", + serde_json::json!({ + "kind": "api_server_enabled", + "backup": backup_path.to_string_lossy(), + "message": "platforms.api_server.enabled 缺失,已自动修复并备份原文件", + }), + ); + Ok(()) +} + // ============================================================================ // .env editor commands (Step 4 / G6) // @@ -3326,3 +3510,108 @@ pub fn hermes_env_delete(key: String) -> Result<(), String> { std::fs::write(&env_path, content).map_err(|e| format!("Failed to write .env: {e}"))?; Ok(()) } + +// ============================================================================ +// Unit tests for the pure YAML helpers (no filesystem I/O). +// ============================================================================ + +#[cfg(test)] +mod guardian_tests { + use super::{config_has_api_server_enabled, patch_yaml_ensure_api_server}; + + #[test] + fn detects_enabled_variants() { + let yaml = "\ +model: + default: deepseek-chat +platforms: + api_server: + enabled: true +"; + assert!(config_has_api_server_enabled(yaml)); + + for v in ["true", "True", "TRUE", "yes", "on", "1"] { + let y = format!("platforms:\n api_server:\n enabled: {v}\n"); + assert!( + config_has_api_server_enabled(&y), + "expected {v} to count as enabled" + ); + } + } + + #[test] + fn detects_missing_or_disabled() { + assert!(!config_has_api_server_enabled("model:\n default: foo\n")); + assert!(!config_has_api_server_enabled( + "platforms:\n other:\n enabled: true\n" + )); + assert!(!config_has_api_server_enabled( + "platforms:\n api_server:\n enabled: false\n" + )); + assert!(!config_has_api_server_enabled( + "platforms:\n api_server:\n something: else\n" + )); + } + + #[test] + fn ignores_commented_enabled() { + let yaml = "platforms:\n api_server:\n # enabled: true\n"; + assert!(!config_has_api_server_enabled(yaml)); + } + + #[test] + fn patch_is_noop_when_already_enabled() { + let yaml = "\ +model: + default: x +platforms: + api_server: + enabled: true +"; + assert_eq!(patch_yaml_ensure_api_server(yaml), yaml); + } + + #[test] + fn patch_appends_when_no_platforms() { + let yaml = "model:\n default: x\n"; + let patched = patch_yaml_ensure_api_server(yaml); + assert!(config_has_api_server_enabled(&patched)); + assert!(patched.contains("model:")); + assert!(patched.contains("default: x")); + } + + #[test] + fn patch_injects_under_existing_platforms() { + let yaml = "\ +platforms: + other: + enabled: true +terminal: + backend: local +"; + let patched = patch_yaml_ensure_api_server(yaml); + assert!(config_has_api_server_enabled(&patched)); + assert!(patched.contains("other:")); + assert!(patched.contains("terminal:")); + assert!(patched.contains("backend: local")); + } + + #[test] + fn patch_replaces_disabled_api_server() { + let yaml = "\ +platforms: + api_server: + enabled: false + extra: keepme + other: + enabled: true +"; + let patched = patch_yaml_ensure_api_server(yaml); + assert!(config_has_api_server_enabled(&patched)); + assert!(patched.contains("other:")); + assert!( + !patched.contains("enabled: false"), + "disabled marker should have been removed" + ); + } +} diff --git a/src/engines/hermes/pages/dashboard.js b/src/engines/hermes/pages/dashboard.js index 6a001c8..2377734 100644 --- a/src/engines/hermes/pages/dashboard.js +++ b/src/engines/hermes/pages/dashboard.js @@ -583,6 +583,14 @@ export function render() { showGwMsg(evt.payload || '', false) }) unlisteners.push(unlisten2) + + // 监听 config.yaml 自愈事件(api_server guardian) + const unlisten3 = await tauriListen('hermes-config-patched', async (evt) => { + const { toast } = await import('../../../components/toast.js') + const msg = evt?.payload?.message || 'config.yaml 已自动修复' + toast(msg, 'info', { duration: 6000 }) + }) + unlisteners.push(unlisten3) } catch (_) { // Web 模式下无 Tauri 事件,静默忽略 }