From 0b4a70e7a4cecea3e715611428b636fd19ce23ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Wed, 27 May 2026 04:17:17 +0800 Subject: [PATCH] feat(hermes): add terminal cloud runtime controls --- scripts/dev-api.js | 20 ++++++ src-tauri/src/commands/hermes.rs | 101 +++++++++++++++++++++++++++ src/engines/hermes/pages/config.js | 18 +++++ src/locales/modules/engine.js | 10 ++- tests/hermes-config-page-ui.test.js | 2 + tests/hermes-terminal-config.test.js | 18 +++++ 6 files changed, 168 insertions(+), 1 deletion(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 1fc15e4..5630dd9 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3324,6 +3324,8 @@ const HERMES_SESSION_RESET_MODES = new Set(['both', 'idle', 'daily', 'none']) const HERMES_STREAMING_TRANSPORTS = new Set(['auto', 'draft', 'edit', 'off']) const HERMES_CODE_EXECUTION_MODES = new Set(['project', 'strict']) const HERMES_TERMINAL_BACKENDS = new Set(['local', 'ssh', 'docker', 'singularity', 'modal', 'daytona', 'vercel_sandbox']) +const HERMES_TERMINAL_MODAL_MODES = new Set(['auto', 'managed', 'direct']) +const HERMES_TERMINAL_VERCEL_RUNTIMES = new Set(['node24', 'node22', 'python3.13']) const HERMES_BROWSER_ENGINES = new Set(['auto', 'lightpanda', 'chrome']) const HERMES_BROWSER_DIALOG_POLICIES = new Set(['must_respond', 'auto_dismiss', 'auto_accept']) const HERMES_STT_PROVIDERS = new Set(['auto', 'local', 'groq', 'openai', 'mistral']) @@ -3458,6 +3460,20 @@ function normalizeHermesTerminalBackend(value, strict = false) { return 'local' } +function normalizeHermesTerminalModalMode(value, strict = false) { + const mode = String(value ?? '').trim().toLowerCase() || 'auto' + if (HERMES_TERMINAL_MODAL_MODES.has(mode)) return mode + if (strict) throw new Error('terminal.modal_mode 必须是 auto、managed 或 direct') + return 'auto' +} + +function normalizeHermesTerminalVercelRuntime(value, strict = false) { + const runtime = String(value ?? '').trim().toLowerCase() || 'node24' + if (HERMES_TERMINAL_VERCEL_RUNTIMES.has(runtime)) return runtime + if (strict) throw new Error('terminal.vercel_runtime 必须是 node24、node22 或 python3.13') + return 'node24' +} + function normalizeHermesBrowserEngine(value, strict = false) { const engine = String(value ?? '').trim().toLowerCase() || 'auto' if (HERMES_BROWSER_ENGINES.has(engine)) return engine @@ -5636,6 +5652,8 @@ export function buildHermesTerminalConfigValues(config = {}) { terminalDockerImage: typeof terminal.docker_image === 'string' ? terminal.docker_image.trim() : '', terminalSingularityImage: typeof terminal.singularity_image === 'string' ? terminal.singularity_image.trim() : '', terminalModalImage: typeof terminal.modal_image === 'string' ? terminal.modal_image.trim() : '', + terminalModalMode: normalizeHermesTerminalModalMode(terminal.modal_mode, false), + terminalVercelRuntime: normalizeHermesTerminalVercelRuntime(terminal.vercel_runtime, false), terminalDaytonaImage: typeof terminal.daytona_image === 'string' ? terminal.daytona_image.trim() : '', terminalDockerForwardEnv: normalizeHermesEnvNameList(terminal.docker_forward_env || [], 'terminal.docker_forward_env').join('\n'), terminalSshHost: typeof terminal.ssh_host === 'string' ? terminal.ssh_host.trim() : '', @@ -5669,6 +5687,8 @@ export function mergeHermesTerminalConfig(config = {}, form = {}) { else delete terminal.env_passthrough terminal.docker_mount_cwd_to_workspace = formHermesBool(form, 'terminalDockerMountCwdToWorkspace', currentValues.terminalDockerMountCwdToWorkspace) terminal.docker_run_as_host_user = formHermesBool(form, 'terminalDockerRunAsHostUser', currentValues.terminalDockerRunAsHostUser) + terminal.modal_mode = normalizeHermesTerminalModalMode(Object.hasOwn(form, 'terminalModalMode') ? form.terminalModalMode : currentValues.terminalModalMode, true) + terminal.vercel_runtime = normalizeHermesTerminalVercelRuntime(Object.hasOwn(form, 'terminalVercelRuntime') ? form.terminalVercelRuntime : currentValues.terminalVercelRuntime, true) for (const [formKey, yamlKey] of [ ['terminalDockerImage', 'docker_image'], ['terminalSingularityImage', 'singularity_image'], diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 77eaa5a..be1eb6c 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -7079,6 +7079,46 @@ fn normalize_hermes_terminal_backend( } } +fn normalize_hermes_terminal_modal_mode( + value: Option, + strict: bool, +) -> Result { + let mode = value.unwrap_or_default().trim().to_ascii_lowercase(); + let mode = if mode.is_empty() { + "auto".to_string() + } else { + mode + }; + if matches!(mode.as_str(), "auto" | "managed" | "direct") { + return Ok(mode); + } + if strict { + Err("terminal.modal_mode 必须是 auto、managed 或 direct".to_string()) + } else { + Ok("auto".to_string()) + } +} + +fn normalize_hermes_terminal_vercel_runtime( + value: Option, + strict: bool, +) -> Result { + let runtime = value.unwrap_or_default().trim().to_ascii_lowercase(); + let runtime = if runtime.is_empty() { + "node24".to_string() + } else { + runtime + }; + if matches!(runtime.as_str(), "node24" | "node22" | "python3.13") { + return Ok(runtime); + } + if strict { + Err("terminal.vercel_runtime 必须是 node24、node22 或 python3.13".to_string()) + } else { + Ok("node24".to_string()) + } +} + fn normalize_hermes_browser_engine(value: Option, strict: bool) -> Result { let engine = value.unwrap_or_default().trim().to_ascii_lowercase(); let engine = if engine.is_empty() { @@ -8964,6 +9004,16 @@ fn build_hermes_terminal_config_values(config: &serde_yaml::Value) -> Value { let terminal_docker_image = terminal_string("docker_image"); let terminal_singularity_image = terminal_string("singularity_image"); let terminal_modal_image = terminal_string("modal_image"); + let terminal_modal_mode = normalize_hermes_terminal_modal_mode( + terminal.and_then(|map| yaml_string_field(map, "modal_mode")), + false, + ) + .unwrap_or_else(|_| "auto".to_string()); + let terminal_vercel_runtime = normalize_hermes_terminal_vercel_runtime( + terminal.and_then(|map| yaml_string_field(map, "vercel_runtime")), + false, + ) + .unwrap_or_else(|_| "node24".to_string()); let terminal_daytona_image = terminal_string("daytona_image"); let terminal_docker_forward_env = terminal .map(|map| yaml_string_sequence_field(map, "docker_forward_env").join("\n")) @@ -9001,6 +9051,8 @@ fn build_hermes_terminal_config_values(config: &serde_yaml::Value) -> Value { "terminalDockerImage": terminal_docker_image, "terminalSingularityImage": terminal_singularity_image, "terminalModalImage": terminal_modal_image, + "terminalModalMode": terminal_modal_mode, + "terminalVercelRuntime": terminal_vercel_runtime, "terminalDaytonaImage": terminal_daytona_image, "terminalDockerForwardEnv": terminal_docker_forward_env, "terminalSshHost": terminal_ssh_host, @@ -9098,6 +9150,26 @@ fn merge_hermes_terminal_config( .as_bool() .unwrap_or(false) }); + let terminal_modal_mode = normalize_hermes_terminal_modal_mode( + if form.get("terminalModalMode").is_some() { + form_string(form, "terminalModalMode") + } else { + current["terminalModalMode"] + .as_str() + .map(ToString::to_string) + }, + true, + )?; + let terminal_vercel_runtime = normalize_hermes_terminal_vercel_runtime( + if form.get("terminalVercelRuntime").is_some() { + form_string(form, "terminalVercelRuntime") + } else { + current["terminalVercelRuntime"] + .as_str() + .map(ToString::to_string) + }, + true, + )?; let terminal_docker_image = form_string(form, "terminalDockerImage") .or_else(|| { current["terminalDockerImage"] @@ -9268,6 +9340,14 @@ fn merge_hermes_terminal_config( set_optional_yaml_string(terminal, "docker_image", terminal_docker_image); set_optional_yaml_string(terminal, "singularity_image", terminal_singularity_image); set_optional_yaml_string(terminal, "modal_image", terminal_modal_image); + terminal.insert( + yaml_key("modal_mode"), + serde_yaml::Value::String(terminal_modal_mode), + ); + terminal.insert( + yaml_key("vercel_runtime"), + serde_yaml::Value::String(terminal_vercel_runtime), + ); set_optional_yaml_string(terminal, "daytona_image", terminal_daytona_image); if terminal_docker_forward_env.is_empty() { terminal.remove(yaml_key("docker_forward_env")); @@ -18450,6 +18530,8 @@ mod hermes_terminal_config_tests { assert_eq!(values["terminalDockerImage"], ""); assert_eq!(values["terminalSingularityImage"], ""); assert_eq!(values["terminalModalImage"], ""); + assert_eq!(values["terminalModalMode"], "auto"); + assert_eq!(values["terminalVercelRuntime"], "node24"); assert_eq!(values["terminalDaytonaImage"], ""); assert_eq!(values["terminalDockerForwardEnv"], ""); assert_eq!(values["terminalSshHost"], ""); @@ -18483,6 +18565,8 @@ terminal: - NPM_TOKEN singularity_image: docker://nikolaik/python-nodejs:python3.11-nodejs20 modal_image: python:3.12 + modal_mode: managed + vercel_runtime: python3.13 daytona_image: ubuntu:24.04 ssh_host: build.example.com ssh_user: deploy @@ -18525,6 +18609,8 @@ terminal: "docker://nikolaik/python-nodejs:python3.11-nodejs20" ); assert_eq!(values["terminalModalImage"], "python:3.12"); + assert_eq!(values["terminalModalMode"], "managed"); + assert_eq!(values["terminalVercelRuntime"], "python3.13"); assert_eq!(values["terminalDaytonaImage"], "ubuntu:24.04"); assert_eq!(values["terminalSshHost"], "build.example.com"); assert_eq!(values["terminalSshUser"], "deploy"); @@ -18575,6 +18661,8 @@ streaming: "terminalDockerForwardEnv": "GITHUB_TOKEN\nNPM_TOKEN\nGITHUB_TOKEN", "terminalSingularityImage": "docker://ubuntu:24.04", "terminalModalImage": "debian:bookworm", + "terminalModalMode": "direct", + "terminalVercelRuntime": "node22", "terminalDaytonaImage": "ubuntu:22.04", "terminalSshHost": "ssh.example.com", "terminalSshUser": "hermes", @@ -18652,6 +18740,11 @@ streaming: config["terminal"]["modal_image"].as_str(), Some("debian:bookworm") ); + assert_eq!(config["terminal"]["modal_mode"].as_str(), Some("direct")); + assert_eq!( + config["terminal"]["vercel_runtime"].as_str(), + Some("node22") + ); assert_eq!( config["terminal"]["daytona_image"].as_str(), Some("ubuntu:22.04") @@ -18852,6 +18945,14 @@ terminal: merge_hermes_terminal_config(&mut config, &json!({ "terminalBackend": "unsafe" })) .unwrap_err(); assert!(err.contains("terminal.backend")); + let err = + merge_hermes_terminal_config(&mut config, &json!({ "terminalModalMode": "unsafe" })) + .unwrap_err(); + assert!(err.contains("terminal.modal_mode")); + let err = + merge_hermes_terminal_config(&mut config, &json!({ "terminalVercelRuntime": "ruby" })) + .unwrap_err(); + assert!(err.contains("terminal.vercel_runtime")); let err = merge_hermes_terminal_config(&mut config, &json!({ "terminalTimeout": 0 })) .unwrap_err(); assert!(err.contains("terminal.timeout")); diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 89ca7a8..8a75faf 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -339,6 +339,8 @@ const TERMINAL_DEFAULTS = { terminalDockerImage: '', terminalSingularityImage: '', terminalModalImage: '', + terminalModalMode: 'auto', + terminalVercelRuntime: 'node24', terminalDaytonaImage: '', terminalDockerForwardEnv: '', terminalSshHost: '', @@ -355,6 +357,8 @@ const SESSION_RESET_MODES = ['both', 'idle', 'daily', 'none'] const STREAMING_TRANSPORTS = ['edit', 'auto', 'draft', 'off'] const CODE_EXECUTION_MODES = ['project', 'strict'] const TERMINAL_BACKENDS = ['local', 'ssh', 'docker', 'singularity', 'modal', 'daytona', 'vercel_sandbox'] +const TERMINAL_MODAL_MODES = ['auto', 'managed', 'direct'] +const TERMINAL_VERCEL_RUNTIMES = ['node24', 'node22', 'python3.13'] const BROWSER_ENGINES = ['auto', 'lightpanda', 'chrome'] const BROWSER_DIALOG_POLICIES = ['must_respond', 'auto_dismiss', 'auto_accept'] const STT_PROVIDERS = ['auto', 'local', 'groq', 'openai', 'mistral'] @@ -2508,6 +2512,18 @@ export function render() { ${t('engine.hermesTerminalConfigModalImage')} + +