mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 04:10:00 +08:00
feat(hermes): add terminal execution config form
This commit is contained in:
@@ -3323,6 +3323,7 @@ function normalizeHermesPlatform(platform) {
|
||||
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_DISPLAY_TOOL_PROGRESS_VALUES = new Set(['off', 'new', 'all', 'verbose'])
|
||||
const HERMES_DISPLAY_STREAMING_VALUES = new Set(['inherit', 'true', 'false'])
|
||||
|
||||
@@ -3390,6 +3391,13 @@ function normalizeHermesCodeExecutionMode(value, strict = false) {
|
||||
return 'project'
|
||||
}
|
||||
|
||||
function normalizeHermesTerminalBackend(value, strict = false) {
|
||||
const backend = String(value ?? '').trim().toLowerCase() || 'local'
|
||||
if (HERMES_TERMINAL_BACKENDS.has(backend)) return backend
|
||||
if (strict) throw new Error('terminal.backend 必须是 local、ssh、docker、singularity、modal、daytona 或 vercel_sandbox')
|
||||
return 'local'
|
||||
}
|
||||
|
||||
function normalizeHermesDisplayToolProgress(value, strict = false, key = 'display.tool_progress') {
|
||||
const progress = String(value ?? '').trim().toLowerCase() || 'all'
|
||||
if (HERMES_DISPLAY_TOOL_PROGRESS_VALUES.has(progress)) return progress
|
||||
@@ -3615,6 +3623,45 @@ export function buildHermesExecutionLimitsConfigValues(config = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
export function buildHermesTerminalConfigValues(config = {}) {
|
||||
const root = config && typeof config === 'object' && !Array.isArray(config) ? config : {}
|
||||
const terminal = root.terminal && typeof root.terminal === 'object' && !Array.isArray(root.terminal)
|
||||
? root.terminal
|
||||
: {}
|
||||
return {
|
||||
terminalBackend: normalizeHermesTerminalBackend(terminal.backend, false),
|
||||
terminalCwd: typeof terminal.cwd === 'string' && terminal.cwd.trim() ? terminal.cwd : '.',
|
||||
terminalTimeout: parseHermesInteger(terminal.timeout, 'terminal.timeout', 180, 1, 86400, false),
|
||||
terminalLifetimeSeconds: parseHermesInteger(terminal.lifetime_seconds, 'terminal.lifetime_seconds', 300, 0, 86400, false),
|
||||
terminalDockerMountCwdToWorkspace: readHermesBool(terminal.docker_mount_cwd_to_workspace, false),
|
||||
terminalDockerRunAsHostUser: readHermesBool(terminal.docker_run_as_host_user, false),
|
||||
terminalContainerCpu: parseHermesInteger(terminal.container_cpu, 'terminal.container_cpu', 1, 1, 64, false),
|
||||
terminalContainerMemory: parseHermesInteger(terminal.container_memory, 'terminal.container_memory', 5120, 128, 1048576, false),
|
||||
terminalContainerDisk: parseHermesInteger(terminal.container_disk, 'terminal.container_disk', 51200, 1024, 10485760, false),
|
||||
terminalContainerPersistent: readHermesBool(terminal.container_persistent, true),
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeHermesTerminalConfig(config = {}, form = {}) {
|
||||
const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {})
|
||||
const currentValues = buildHermesTerminalConfigValues(next)
|
||||
const terminal = next.terminal && typeof next.terminal === 'object' && !Array.isArray(next.terminal)
|
||||
? mergeConfigsPreservingFields(next.terminal, {})
|
||||
: {}
|
||||
terminal.backend = normalizeHermesTerminalBackend(Object.hasOwn(form, 'terminalBackend') ? form.terminalBackend : currentValues.terminalBackend, true)
|
||||
terminal.cwd = String(Object.hasOwn(form, 'terminalCwd') ? form.terminalCwd : currentValues.terminalCwd).trim() || '.'
|
||||
terminal.timeout = parseHermesInteger(Object.hasOwn(form, 'terminalTimeout') ? form.terminalTimeout : currentValues.terminalTimeout, 'terminal.timeout', 180, 1, 86400, true)
|
||||
terminal.lifetime_seconds = parseHermesInteger(Object.hasOwn(form, 'terminalLifetimeSeconds') ? form.terminalLifetimeSeconds : currentValues.terminalLifetimeSeconds, 'terminal.lifetime_seconds', 300, 0, 86400, true)
|
||||
terminal.docker_mount_cwd_to_workspace = formHermesBool(form, 'terminalDockerMountCwdToWorkspace', currentValues.terminalDockerMountCwdToWorkspace)
|
||||
terminal.docker_run_as_host_user = formHermesBool(form, 'terminalDockerRunAsHostUser', currentValues.terminalDockerRunAsHostUser)
|
||||
terminal.container_cpu = parseHermesInteger(Object.hasOwn(form, 'terminalContainerCpu') ? form.terminalContainerCpu : currentValues.terminalContainerCpu, 'terminal.container_cpu', 1, 1, 64, true)
|
||||
terminal.container_memory = parseHermesInteger(Object.hasOwn(form, 'terminalContainerMemory') ? form.terminalContainerMemory : currentValues.terminalContainerMemory, 'terminal.container_memory', 5120, 128, 1048576, true)
|
||||
terminal.container_disk = parseHermesInteger(Object.hasOwn(form, 'terminalContainerDisk') ? form.terminalContainerDisk : currentValues.terminalContainerDisk, 'terminal.container_disk', 51200, 1024, 10485760, true)
|
||||
terminal.container_persistent = formHermesBool(form, 'terminalContainerPersistent', currentValues.terminalContainerPersistent)
|
||||
next.terminal = terminal
|
||||
return next
|
||||
}
|
||||
|
||||
export function mergeHermesExecutionLimitsConfig(config = {}, form = {}) {
|
||||
const next = mergeConfigsPreservingFields({}, config && typeof config === 'object' && !Array.isArray(config) ? config : {})
|
||||
const currentValues = buildHermesExecutionLimitsConfigValues(next)
|
||||
@@ -10031,6 +10078,27 @@ const handlers = {
|
||||
}
|
||||
},
|
||||
|
||||
hermes_terminal_config_read() {
|
||||
const { configPath, exists, config } = readHermesConfigYamlObject()
|
||||
return {
|
||||
exists,
|
||||
configPath,
|
||||
values: buildHermesTerminalConfigValues(config),
|
||||
}
|
||||
},
|
||||
|
||||
hermes_terminal_config_save({ form } = {}) {
|
||||
const { configPath, config } = readHermesConfigYamlObject()
|
||||
const next = mergeHermesTerminalConfig(config, form || {})
|
||||
const backup = writeHermesConfigYamlObject(configPath, next)
|
||||
return {
|
||||
ok: true,
|
||||
configPath,
|
||||
backup,
|
||||
values: buildHermesTerminalConfigValues(next),
|
||||
}
|
||||
},
|
||||
|
||||
// P1-3 lazy_deps: Web 模式下不能调 venv python,但仍提供 feature 列表 + 提示用户走桌面端装
|
||||
hermes_lazy_deps_features() {
|
||||
const features = [
|
||||
|
||||
@@ -3692,6 +3692,30 @@ fn normalize_hermes_code_execution_mode(
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_hermes_terminal_backend(
|
||||
value: Option<String>,
|
||||
strict: bool,
|
||||
) -> Result<String, String> {
|
||||
let backend = value.unwrap_or_default().trim().to_ascii_lowercase();
|
||||
let backend = if backend.is_empty() {
|
||||
"local".to_string()
|
||||
} else {
|
||||
backend
|
||||
};
|
||||
if matches!(
|
||||
backend.as_str(),
|
||||
"local" | "ssh" | "docker" | "singularity" | "modal" | "daytona" | "vercel_sandbox"
|
||||
) {
|
||||
return Ok(backend);
|
||||
}
|
||||
if strict {
|
||||
Err("terminal.backend 必须是 local、ssh、docker、singularity、modal、daytona 或 vercel_sandbox"
|
||||
.to_string())
|
||||
} else {
|
||||
Ok("local".to_string())
|
||||
}
|
||||
}
|
||||
|
||||
fn hermes_streaming_config_source(config: &serde_yaml::Value) -> Option<&serde_yaml::Mapping> {
|
||||
let root = config.as_mapping()?;
|
||||
if let Some(streaming) = yaml_get_mapping(root, "streaming") {
|
||||
@@ -4021,6 +4045,200 @@ fn merge_hermes_execution_limits_config(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_hermes_terminal_config_values(config: &serde_yaml::Value) -> Value {
|
||||
let root = config.as_mapping();
|
||||
let terminal = root.and_then(|map| yaml_get_mapping(map, "terminal"));
|
||||
let terminal_backend = normalize_hermes_terminal_backend(
|
||||
terminal.and_then(|map| yaml_string_field(map, "backend")),
|
||||
false,
|
||||
)
|
||||
.unwrap_or_else(|_| "local".to_string());
|
||||
let terminal_cwd = terminal
|
||||
.and_then(|map| yaml_string_field(map, "cwd"))
|
||||
.map(|value| value.trim().to_string())
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| ".".to_string());
|
||||
let terminal_timeout = terminal
|
||||
.map(|map| bounded_hermes_i64(yaml_i64_field(map, "timeout"), 180, 1, 86400))
|
||||
.unwrap_or(180);
|
||||
let terminal_lifetime_seconds = terminal
|
||||
.map(|map| bounded_hermes_i64(yaml_i64_field(map, "lifetime_seconds"), 300, 0, 86400))
|
||||
.unwrap_or(300);
|
||||
let terminal_docker_mount_cwd_to_workspace = terminal
|
||||
.and_then(|map| yaml_bool_field(map, "docker_mount_cwd_to_workspace"))
|
||||
.unwrap_or(false);
|
||||
let terminal_docker_run_as_host_user = terminal
|
||||
.and_then(|map| yaml_bool_field(map, "docker_run_as_host_user"))
|
||||
.unwrap_or(false);
|
||||
let terminal_container_cpu = terminal
|
||||
.map(|map| bounded_hermes_i64(yaml_i64_field(map, "container_cpu"), 1, 1, 64))
|
||||
.unwrap_or(1);
|
||||
let terminal_container_memory = terminal
|
||||
.map(|map| bounded_hermes_i64(yaml_i64_field(map, "container_memory"), 5120, 128, 1048576))
|
||||
.unwrap_or(5120);
|
||||
let terminal_container_disk = terminal
|
||||
.map(|map| bounded_hermes_i64(yaml_i64_field(map, "container_disk"), 51200, 1024, 10485760))
|
||||
.unwrap_or(51200);
|
||||
let terminal_container_persistent = terminal
|
||||
.and_then(|map| yaml_bool_field(map, "container_persistent"))
|
||||
.unwrap_or(true);
|
||||
|
||||
serde_json::json!({
|
||||
"terminalBackend": terminal_backend,
|
||||
"terminalCwd": terminal_cwd,
|
||||
"terminalTimeout": terminal_timeout,
|
||||
"terminalLifetimeSeconds": terminal_lifetime_seconds,
|
||||
"terminalDockerMountCwdToWorkspace": terminal_docker_mount_cwd_to_workspace,
|
||||
"terminalDockerRunAsHostUser": terminal_docker_run_as_host_user,
|
||||
"terminalContainerCpu": terminal_container_cpu,
|
||||
"terminalContainerMemory": terminal_container_memory,
|
||||
"terminalContainerDisk": terminal_container_disk,
|
||||
"terminalContainerPersistent": terminal_container_persistent,
|
||||
})
|
||||
}
|
||||
|
||||
fn merge_hermes_terminal_config(
|
||||
config: &mut serde_yaml::Value,
|
||||
form: &Value,
|
||||
) -> Result<(), String> {
|
||||
let current = build_hermes_terminal_config_values(config);
|
||||
let terminal_backend = normalize_hermes_terminal_backend(
|
||||
if form.get("terminalBackend").is_some() {
|
||||
form_string(form, "terminalBackend")
|
||||
} else {
|
||||
current["terminalBackend"].as_str().map(ToString::to_string)
|
||||
},
|
||||
true,
|
||||
)?;
|
||||
let terminal_cwd = if form.get("terminalCwd").is_some() {
|
||||
form_string(form, "terminalCwd")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string()
|
||||
} else {
|
||||
current["terminalCwd"].as_str().unwrap_or(".").to_string()
|
||||
};
|
||||
let terminal_cwd = if terminal_cwd.trim().is_empty() {
|
||||
".".to_string()
|
||||
} else {
|
||||
terminal_cwd
|
||||
};
|
||||
let terminal_timeout = validate_hermes_i64(
|
||||
if form.get("terminalTimeout").is_some() {
|
||||
form_i64(form, "terminalTimeout")
|
||||
} else {
|
||||
Some(current["terminalTimeout"].as_i64().unwrap_or(180))
|
||||
},
|
||||
"terminal.timeout",
|
||||
180,
|
||||
1,
|
||||
86400,
|
||||
)?;
|
||||
let terminal_lifetime_seconds = validate_hermes_i64(
|
||||
if form.get("terminalLifetimeSeconds").is_some() {
|
||||
form_i64(form, "terminalLifetimeSeconds")
|
||||
} else {
|
||||
Some(current["terminalLifetimeSeconds"].as_i64().unwrap_or(300))
|
||||
},
|
||||
"terminal.lifetime_seconds",
|
||||
300,
|
||||
0,
|
||||
86400,
|
||||
)?;
|
||||
let terminal_docker_mount_cwd_to_workspace =
|
||||
form_bool(form, "terminalDockerMountCwdToWorkspace").unwrap_or_else(|| {
|
||||
current["terminalDockerMountCwdToWorkspace"]
|
||||
.as_bool()
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let terminal_docker_run_as_host_user = form_bool(form, "terminalDockerRunAsHostUser")
|
||||
.unwrap_or_else(|| {
|
||||
current["terminalDockerRunAsHostUser"]
|
||||
.as_bool()
|
||||
.unwrap_or(false)
|
||||
});
|
||||
let terminal_container_cpu = validate_hermes_i64(
|
||||
if form.get("terminalContainerCpu").is_some() {
|
||||
form_i64(form, "terminalContainerCpu")
|
||||
} else {
|
||||
Some(current["terminalContainerCpu"].as_i64().unwrap_or(1))
|
||||
},
|
||||
"terminal.container_cpu",
|
||||
1,
|
||||
1,
|
||||
64,
|
||||
)?;
|
||||
let terminal_container_memory = validate_hermes_i64(
|
||||
if form.get("terminalContainerMemory").is_some() {
|
||||
form_i64(form, "terminalContainerMemory")
|
||||
} else {
|
||||
Some(current["terminalContainerMemory"].as_i64().unwrap_or(5120))
|
||||
},
|
||||
"terminal.container_memory",
|
||||
5120,
|
||||
128,
|
||||
1048576,
|
||||
)?;
|
||||
let terminal_container_disk = validate_hermes_i64(
|
||||
if form.get("terminalContainerDisk").is_some() {
|
||||
form_i64(form, "terminalContainerDisk")
|
||||
} else {
|
||||
Some(current["terminalContainerDisk"].as_i64().unwrap_or(51200))
|
||||
},
|
||||
"terminal.container_disk",
|
||||
51200,
|
||||
1024,
|
||||
10485760,
|
||||
)?;
|
||||
let terminal_container_persistent = form_bool(form, "terminalContainerPersistent")
|
||||
.unwrap_or_else(|| {
|
||||
current["terminalContainerPersistent"]
|
||||
.as_bool()
|
||||
.unwrap_or(true)
|
||||
});
|
||||
|
||||
let root = ensure_yaml_object(config)?;
|
||||
let terminal = yaml_child_object(root, "terminal")?;
|
||||
terminal.insert(
|
||||
yaml_key("backend"),
|
||||
serde_yaml::Value::String(terminal_backend),
|
||||
);
|
||||
terminal.insert(yaml_key("cwd"), serde_yaml::Value::String(terminal_cwd));
|
||||
terminal.insert(
|
||||
yaml_key("timeout"),
|
||||
serde_yaml::Value::Number(terminal_timeout.into()),
|
||||
);
|
||||
terminal.insert(
|
||||
yaml_key("lifetime_seconds"),
|
||||
serde_yaml::Value::Number(terminal_lifetime_seconds.into()),
|
||||
);
|
||||
terminal.insert(
|
||||
yaml_key("docker_mount_cwd_to_workspace"),
|
||||
serde_yaml::Value::Bool(terminal_docker_mount_cwd_to_workspace),
|
||||
);
|
||||
terminal.insert(
|
||||
yaml_key("docker_run_as_host_user"),
|
||||
serde_yaml::Value::Bool(terminal_docker_run_as_host_user),
|
||||
);
|
||||
terminal.insert(
|
||||
yaml_key("container_cpu"),
|
||||
serde_yaml::Value::Number(terminal_container_cpu.into()),
|
||||
);
|
||||
terminal.insert(
|
||||
yaml_key("container_memory"),
|
||||
serde_yaml::Value::Number(terminal_container_memory.into()),
|
||||
);
|
||||
terminal.insert(
|
||||
yaml_key("container_disk"),
|
||||
serde_yaml::Value::Number(terminal_container_disk.into()),
|
||||
);
|
||||
terminal.insert(
|
||||
yaml_key("container_persistent"),
|
||||
serde_yaml::Value::Bool(terminal_container_persistent),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build_hermes_session_runtime_config_values(config: &serde_yaml::Value) -> Value {
|
||||
let root = config.as_mapping();
|
||||
let session_reset = root.and_then(|map| yaml_get_mapping(map, "session_reset"));
|
||||
@@ -4952,6 +5170,30 @@ pub fn hermes_execution_limits_config_save(form: Value) -> Result<Value, String>
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn hermes_terminal_config_read() -> Result<Value, String> {
|
||||
let (config_path, exists, config) = read_hermes_channel_yaml_config()?;
|
||||
ensure_yaml_object(&mut config.clone())?;
|
||||
Ok(serde_json::json!({
|
||||
"exists": exists,
|
||||
"configPath": config_path.to_string_lossy(),
|
||||
"values": build_hermes_terminal_config_values(&config),
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn hermes_terminal_config_save(form: Value) -> Result<Value, String> {
|
||||
let (config_path, _exists, mut config) = read_hermes_channel_yaml_config()?;
|
||||
merge_hermes_terminal_config(&mut config, &form)?;
|
||||
let backup = write_hermes_yaml_config(&config_path, &config)?;
|
||||
Ok(serde_json::json!({
|
||||
"ok": true,
|
||||
"configPath": config_path.to_string_lossy(),
|
||||
"backup": backup,
|
||||
"values": build_hermes_terminal_config_values(&config),
|
||||
}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// hermes_read_config — 读取 Hermes config.yaml + .env
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -10444,6 +10686,152 @@ streaming:
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hermes_terminal_config_tests {
|
||||
use super::{build_hermes_terminal_config_values, merge_hermes_terminal_config};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn terminal_values_have_upstream_defaults() {
|
||||
let config: serde_yaml::Value = serde_yaml::from_str("{}").unwrap();
|
||||
let values = build_hermes_terminal_config_values(&config);
|
||||
assert_eq!(values["terminalBackend"], "local");
|
||||
assert_eq!(values["terminalCwd"], ".");
|
||||
assert_eq!(values["terminalTimeout"], 180);
|
||||
assert_eq!(values["terminalLifetimeSeconds"], 300);
|
||||
assert_eq!(values["terminalDockerMountCwdToWorkspace"], false);
|
||||
assert_eq!(values["terminalDockerRunAsHostUser"], false);
|
||||
assert_eq!(values["terminalContainerCpu"], 1);
|
||||
assert_eq!(values["terminalContainerMemory"], 5120);
|
||||
assert_eq!(values["terminalContainerDisk"], 51200);
|
||||
assert_eq!(values["terminalContainerPersistent"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn terminal_values_read_yaml_fields() {
|
||||
let config: serde_yaml::Value = serde_yaml::from_str(
|
||||
r#"
|
||||
terminal:
|
||||
backend: docker
|
||||
cwd: /workspace
|
||||
timeout: 600
|
||||
lifetime_seconds: 1800
|
||||
docker_mount_cwd_to_workspace: true
|
||||
docker_run_as_host_user: true
|
||||
container_cpu: 4
|
||||
container_memory: 8192
|
||||
container_disk: 102400
|
||||
container_persistent: false
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
let values = build_hermes_terminal_config_values(&config);
|
||||
assert_eq!(values["terminalBackend"], "docker");
|
||||
assert_eq!(values["terminalCwd"], "/workspace");
|
||||
assert_eq!(values["terminalTimeout"], 600);
|
||||
assert_eq!(values["terminalLifetimeSeconds"], 1800);
|
||||
assert_eq!(values["terminalDockerMountCwdToWorkspace"], true);
|
||||
assert_eq!(values["terminalDockerRunAsHostUser"], true);
|
||||
assert_eq!(values["terminalContainerCpu"], 4);
|
||||
assert_eq!(values["terminalContainerMemory"], 8192);
|
||||
assert_eq!(values["terminalContainerDisk"], 102400);
|
||||
assert_eq!(values["terminalContainerPersistent"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_terminal_config_preserves_unknown_fields() {
|
||||
let mut config: serde_yaml::Value = serde_yaml::from_str(
|
||||
r#"
|
||||
model:
|
||||
provider: anthropic
|
||||
terminal:
|
||||
backend: local
|
||||
docker_image: custom/python-node
|
||||
docker_forward_env:
|
||||
- GITHUB_TOKEN
|
||||
custom_flag: keep-terminal
|
||||
streaming:
|
||||
enabled: true
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
merge_hermes_terminal_config(
|
||||
&mut config,
|
||||
&json!({
|
||||
"terminalBackend": "docker",
|
||||
"terminalCwd": "/workspace",
|
||||
"terminalTimeout": "900",
|
||||
"terminalLifetimeSeconds": "1200",
|
||||
"terminalDockerMountCwdToWorkspace": true,
|
||||
"terminalDockerRunAsHostUser": true,
|
||||
"terminalContainerCpu": "2",
|
||||
"terminalContainerMemory": "6144",
|
||||
"terminalContainerDisk": "20480",
|
||||
"terminalContainerPersistent": false,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config["model"]["provider"].as_str(), Some("anthropic"));
|
||||
assert_eq!(config["streaming"]["enabled"].as_bool(), Some(true));
|
||||
assert_eq!(config["terminal"]["backend"].as_str(), Some("docker"));
|
||||
assert_eq!(config["terminal"]["cwd"].as_str(), Some("/workspace"));
|
||||
assert_eq!(config["terminal"]["timeout"].as_i64(), Some(900));
|
||||
assert_eq!(config["terminal"]["lifetime_seconds"].as_i64(), Some(1200));
|
||||
assert_eq!(
|
||||
config["terminal"]["docker_mount_cwd_to_workspace"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(
|
||||
config["terminal"]["docker_run_as_host_user"].as_bool(),
|
||||
Some(true)
|
||||
);
|
||||
assert_eq!(config["terminal"]["container_cpu"].as_i64(), Some(2));
|
||||
assert_eq!(config["terminal"]["container_memory"].as_i64(), Some(6144));
|
||||
assert_eq!(config["terminal"]["container_disk"].as_i64(), Some(20480));
|
||||
assert_eq!(
|
||||
config["terminal"]["container_persistent"].as_bool(),
|
||||
Some(false)
|
||||
);
|
||||
assert_eq!(
|
||||
config["terminal"]["docker_image"].as_str(),
|
||||
Some("custom/python-node")
|
||||
);
|
||||
assert_eq!(
|
||||
config["terminal"]["docker_forward_env"][0].as_str(),
|
||||
Some("GITHUB_TOKEN")
|
||||
);
|
||||
assert_eq!(
|
||||
config["terminal"]["custom_flag"].as_str(),
|
||||
Some("keep-terminal")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn merge_terminal_config_rejects_invalid_values() {
|
||||
let mut config = serde_yaml::Value::Mapping(serde_yaml::Mapping::new());
|
||||
let err =
|
||||
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!({ "terminalTimeout": 0 }))
|
||||
.unwrap_err();
|
||||
assert!(err.contains("terminal.timeout"));
|
||||
let err =
|
||||
merge_hermes_terminal_config(&mut config, &json!({ "terminalLifetimeSeconds": -1 }))
|
||||
.unwrap_err();
|
||||
assert!(err.contains("terminal.lifetime_seconds"));
|
||||
let err = merge_hermes_terminal_config(&mut config, &json!({ "terminalContainerCpu": 0 }))
|
||||
.unwrap_err();
|
||||
assert!(err.contains("terminal.container_cpu"));
|
||||
let err =
|
||||
merge_hermes_terminal_config(&mut config, &json!({ "terminalContainerMemory": 127 }))
|
||||
.unwrap_err();
|
||||
assert!(err.contains("terminal.container_memory"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hermes_memory_config_tests {
|
||||
use super::{build_hermes_memory_config_values, merge_hermes_memory_config};
|
||||
|
||||
@@ -269,6 +269,8 @@ pub fn run() {
|
||||
hermes::hermes_streaming_config_save,
|
||||
hermes::hermes_execution_limits_config_read,
|
||||
hermes::hermes_execution_limits_config_save,
|
||||
hermes::hermes_terminal_config_read,
|
||||
hermes::hermes_terminal_config_save,
|
||||
hermes::hermes_lazy_deps_features,
|
||||
hermes::hermes_lazy_deps_status,
|
||||
hermes::hermes_lazy_deps_ensure,
|
||||
|
||||
@@ -64,9 +64,23 @@ const EXECUTION_LIMITS_DEFAULTS = {
|
||||
delegationInheritMcpToolsets: true,
|
||||
}
|
||||
|
||||
const TERMINAL_DEFAULTS = {
|
||||
terminalBackend: 'local',
|
||||
terminalCwd: '.',
|
||||
terminalTimeout: 180,
|
||||
terminalLifetimeSeconds: 300,
|
||||
terminalDockerMountCwdToWorkspace: false,
|
||||
terminalDockerRunAsHostUser: false,
|
||||
terminalContainerCpu: 1,
|
||||
terminalContainerMemory: 5120,
|
||||
terminalContainerDisk: 51200,
|
||||
terminalContainerPersistent: true,
|
||||
}
|
||||
|
||||
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']
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
@@ -79,6 +93,7 @@ export function render() {
|
||||
let memoryValues = { ...MEMORY_DEFAULTS }
|
||||
let streamingValues = { ...STREAMING_DEFAULTS }
|
||||
let executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS }
|
||||
let terminalValues = { ...TERMINAL_DEFAULTS }
|
||||
let loading = true
|
||||
let runtimeLoading = true
|
||||
let compressionLoading = true
|
||||
@@ -86,6 +101,7 @@ export function render() {
|
||||
let memoryLoading = true
|
||||
let streamingLoading = true
|
||||
let executionLimitsLoading = true
|
||||
let terminalLoading = true
|
||||
let saving = false
|
||||
let runtimeSaving = false
|
||||
let compressionSaving = false
|
||||
@@ -93,6 +109,7 @@ export function render() {
|
||||
let memorySaving = false
|
||||
let streamingSaving = false
|
||||
let executionLimitsSaving = false
|
||||
let terminalSaving = false
|
||||
let error = null
|
||||
let runtimeError = null
|
||||
let compressionError = null
|
||||
@@ -100,6 +117,7 @@ export function render() {
|
||||
let memoryError = null
|
||||
let streamingError = null
|
||||
let executionLimitsError = null
|
||||
let terminalError = null
|
||||
|
||||
function esc(value) {
|
||||
return String(value ?? '')
|
||||
@@ -110,7 +128,7 @@ export function render() {
|
||||
}
|
||||
|
||||
function isBusy() {
|
||||
return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || streamingLoading || executionLimitsLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving
|
||||
return loading || runtimeLoading || compressionLoading || toolGuardrailsLoading || memoryLoading || streamingLoading || executionLimitsLoading || terminalLoading || saving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving || terminalSaving
|
||||
}
|
||||
|
||||
function option(labelKey, value, selected) {
|
||||
@@ -127,7 +145,7 @@ export function render() {
|
||||
}
|
||||
|
||||
function renderRuntimePanel() {
|
||||
const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving
|
||||
const disabled = loading || saving || runtimeLoading || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving || terminalSaving
|
||||
return `
|
||||
<div class="hm-panel hm-config-runtime-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -175,7 +193,7 @@ export function render() {
|
||||
}
|
||||
|
||||
function renderCompressionPanel() {
|
||||
const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving
|
||||
const disabled = loading || saving || compressionLoading || compressionSaving || runtimeSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving || terminalSaving
|
||||
return `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-compression-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -225,7 +243,7 @@ export function render() {
|
||||
}
|
||||
|
||||
function renderToolGuardrailsPanel() {
|
||||
const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving || memorySaving || streamingSaving || executionLimitsSaving
|
||||
const disabled = loading || saving || toolGuardrailsLoading || toolGuardrailsSaving || runtimeSaving || compressionSaving || memorySaving || streamingSaving || executionLimitsSaving || terminalSaving
|
||||
return `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-guardrails-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -287,7 +305,7 @@ export function render() {
|
||||
}
|
||||
|
||||
function renderMemoryPanel() {
|
||||
const disabled = loading || saving || memoryLoading || memorySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || streamingSaving || executionLimitsSaving
|
||||
const disabled = loading || saving || memoryLoading || memorySaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || streamingSaving || executionLimitsSaving || terminalSaving
|
||||
return `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-memory-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -333,7 +351,7 @@ export function render() {
|
||||
}
|
||||
|
||||
function renderStreamingPanel() {
|
||||
const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || executionLimitsSaving
|
||||
const disabled = loading || saving || streamingLoading || streamingSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || executionLimitsSaving || terminalSaving
|
||||
return `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-streaming-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -385,7 +403,7 @@ export function render() {
|
||||
}
|
||||
|
||||
function renderExecutionLimitsPanel() {
|
||||
const disabled = loading || saving || executionLimitsLoading || executionLimitsSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving
|
||||
const disabled = loading || saving || executionLimitsLoading || executionLimitsSaving || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving
|
||||
return `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-execution-limits-panel">
|
||||
<div class="hm-panel-header">
|
||||
@@ -456,6 +474,77 @@ export function render() {
|
||||
`
|
||||
}
|
||||
|
||||
function renderTerminalPanel() {
|
||||
const disabled = loading || saving || terminalLoading || terminalSaving || runtimeSaving || compressionSaving || toolGuardrailsSaving || memorySaving || streamingSaving || executionLimitsSaving
|
||||
return `
|
||||
<div class="hm-panel hm-config-runtime-panel hm-config-terminal-panel">
|
||||
<div class="hm-panel-header">
|
||||
<div>
|
||||
<div class="hm-panel-title">${t('engine.hermesTerminalConfigTitle')}</div>
|
||||
<div class="hm-channel-panel-desc">${t('engine.hermesTerminalConfigDesc')}</div>
|
||||
</div>
|
||||
<div class="hm-panel-actions">
|
||||
<span class="hm-muted">${terminalSaving ? t('engine.hermesConfigStatusSaving') : terminalLoading ? t('engine.hermesConfigStatusLoading') : t('engine.hermesTerminalConfigStatusReady')}</span>
|
||||
<button class="hm-btn hm-btn--cta hm-btn--sm" id="hm-terminal-save" ${disabled ? 'disabled' : ''}>${t('engine.hermesTerminalConfigSave')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hm-panel-body">
|
||||
${renderError(terminalError)}
|
||||
<div class="hm-config-runtime-grid hm-config-terminal-grid">
|
||||
<label class="hm-field">
|
||||
<span class="hm-field-label">${t('engine.hermesTerminalConfigBackend')}</span>
|
||||
<select id="hm-terminal-backend" class="hm-input" ${disabled ? 'disabled' : ''}>
|
||||
${TERMINAL_BACKENDS.map(mode => option(`engine.hermesTerminalConfigBackend_${mode}`, mode, terminalValues.terminalBackend)).join('')}
|
||||
</select>
|
||||
</label>
|
||||
<label class="hm-field">
|
||||
<span class="hm-field-label">${t('engine.hermesTerminalConfigCwd')}</span>
|
||||
<input id="hm-terminal-cwd" class="hm-input" value="${esc(terminalValues.terminalCwd)}" ${disabled ? 'disabled' : ''}>
|
||||
</label>
|
||||
<label class="hm-field">
|
||||
<span class="hm-field-label">${t('engine.hermesTerminalConfigTimeout')}</span>
|
||||
<input id="hm-terminal-timeout" class="hm-input" type="number" inputmode="numeric" min="1" max="86400" step="1" value="${esc(terminalValues.terminalTimeout)}" ${disabled ? 'disabled' : ''}>
|
||||
</label>
|
||||
<label class="hm-field">
|
||||
<span class="hm-field-label">${t('engine.hermesTerminalConfigLifetimeSeconds')}</span>
|
||||
<input id="hm-terminal-lifetime-seconds" class="hm-input" type="number" inputmode="numeric" min="0" max="86400" step="1" value="${esc(terminalValues.terminalLifetimeSeconds)}" ${disabled ? 'disabled' : ''}>
|
||||
</label>
|
||||
</div>
|
||||
<div class="hm-config-check-grid">
|
||||
<label class="hm-channel-check hm-channel-check--danger">
|
||||
<input id="hm-terminal-docker-mount-cwd-to-workspace" type="checkbox" ${terminalValues.terminalDockerMountCwdToWorkspace ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
|
||||
<span>${t('engine.hermesTerminalConfigDockerMountCwd')}</span>
|
||||
</label>
|
||||
<label class="hm-channel-check">
|
||||
<input id="hm-terminal-docker-run-as-host-user" type="checkbox" ${terminalValues.terminalDockerRunAsHostUser ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
|
||||
<span>${t('engine.hermesTerminalConfigDockerRunAsHostUser')}</span>
|
||||
</label>
|
||||
<label class="hm-channel-check">
|
||||
<input id="hm-terminal-container-persistent" type="checkbox" ${terminalValues.terminalContainerPersistent ? 'checked' : ''} ${disabled ? 'disabled' : ''}>
|
||||
<span>${t('engine.hermesTerminalConfigContainerPersistent')}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="hm-config-subtitle">${t('engine.hermesTerminalConfigContainerTitle')}</div>
|
||||
<div class="hm-config-runtime-grid hm-config-terminal-grid">
|
||||
<label class="hm-field">
|
||||
<span class="hm-field-label">${t('engine.hermesTerminalConfigContainerCpu')}</span>
|
||||
<input id="hm-terminal-container-cpu" class="hm-input" type="number" inputmode="numeric" min="1" max="64" step="1" value="${esc(terminalValues.terminalContainerCpu)}" ${disabled ? 'disabled' : ''}>
|
||||
</label>
|
||||
<label class="hm-field">
|
||||
<span class="hm-field-label">${t('engine.hermesTerminalConfigContainerMemory')}</span>
|
||||
<input id="hm-terminal-container-memory" class="hm-input" type="number" inputmode="numeric" min="128" max="1048576" step="128" value="${esc(terminalValues.terminalContainerMemory)}" ${disabled ? 'disabled' : ''}>
|
||||
</label>
|
||||
<label class="hm-field">
|
||||
<span class="hm-field-label">${t('engine.hermesTerminalConfigContainerDisk')}</span>
|
||||
<input id="hm-terminal-container-disk" class="hm-input" type="number" inputmode="numeric" min="1024" max="10485760" step="1024" value="${esc(terminalValues.terminalContainerDisk)}" ${disabled ? 'disabled' : ''}>
|
||||
</label>
|
||||
</div>
|
||||
<div class="hm-channel-footnote">${t('engine.hermesTerminalConfigFootnote')}</div>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function draw() {
|
||||
el.innerHTML = `
|
||||
<div class="hm-hero">
|
||||
@@ -471,6 +560,7 @@ export function render() {
|
||||
</div>
|
||||
|
||||
${renderRuntimePanel()}
|
||||
${renderTerminalPanel()}
|
||||
${renderStreamingPanel()}
|
||||
${renderExecutionLimitsPanel()}
|
||||
${renderCompressionPanel()}
|
||||
@@ -501,6 +591,7 @@ export function render() {
|
||||
el.querySelector('#hm-memory-save')?.addEventListener('click', saveMemory)
|
||||
el.querySelector('#hm-streaming-save')?.addEventListener('click', saveStreaming)
|
||||
el.querySelector('#hm-execution-limits-save')?.addEventListener('click', saveExecutionLimits)
|
||||
el.querySelector('#hm-terminal-save')?.addEventListener('click', saveTerminal)
|
||||
}
|
||||
|
||||
async function loadRaw() {
|
||||
@@ -538,6 +629,11 @@ export function render() {
|
||||
executionLimitsValues = { ...EXECUTION_LIMITS_DEFAULTS, ...(data?.values || {}) }
|
||||
}
|
||||
|
||||
async function loadTerminal() {
|
||||
const data = await api.hermesTerminalConfigRead()
|
||||
terminalValues = { ...TERMINAL_DEFAULTS, ...(data?.values || {}) }
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
runtimeLoading = true
|
||||
@@ -546,6 +642,7 @@ export function render() {
|
||||
memoryLoading = true
|
||||
streamingLoading = true
|
||||
executionLimitsLoading = true
|
||||
terminalLoading = true
|
||||
error = null
|
||||
runtimeError = null
|
||||
compressionError = null
|
||||
@@ -553,6 +650,7 @@ export function render() {
|
||||
memoryError = null
|
||||
streamingError = null
|
||||
executionLimitsError = null
|
||||
terminalError = null
|
||||
draw()
|
||||
try {
|
||||
await loadRaw()
|
||||
@@ -601,6 +699,14 @@ export function render() {
|
||||
executionLimitsLoading = false
|
||||
draw()
|
||||
}
|
||||
try {
|
||||
await loadTerminal()
|
||||
} catch (err) {
|
||||
terminalError = humanizeError(err, t('engine.hermesTerminalConfigLoadFailed') || 'Load terminal config failed')
|
||||
} finally {
|
||||
terminalLoading = false
|
||||
draw()
|
||||
}
|
||||
try {
|
||||
await loadMemory()
|
||||
} catch (err) {
|
||||
@@ -648,6 +754,9 @@ export function render() {
|
||||
try {
|
||||
await loadExecutionLimits()
|
||||
} catch {}
|
||||
try {
|
||||
await loadTerminal()
|
||||
} catch {}
|
||||
} catch (err) {
|
||||
error = humanizeError(err, t('engine.hermesConfigSaveFailed') || 'Save failed')
|
||||
toast(error, 'error')
|
||||
@@ -841,6 +950,40 @@ export function render() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveTerminal() {
|
||||
const form = {
|
||||
terminalBackend: el.querySelector('#hm-terminal-backend')?.value || 'local',
|
||||
terminalCwd: el.querySelector('#hm-terminal-cwd')?.value || '.',
|
||||
terminalTimeout: el.querySelector('#hm-terminal-timeout')?.value || '180',
|
||||
terminalLifetimeSeconds: el.querySelector('#hm-terminal-lifetime-seconds')?.value || '300',
|
||||
terminalDockerMountCwdToWorkspace: !!el.querySelector('#hm-terminal-docker-mount-cwd-to-workspace')?.checked,
|
||||
terminalDockerRunAsHostUser: !!el.querySelector('#hm-terminal-docker-run-as-host-user')?.checked,
|
||||
terminalContainerCpu: el.querySelector('#hm-terminal-container-cpu')?.value || '1',
|
||||
terminalContainerMemory: el.querySelector('#hm-terminal-container-memory')?.value || '5120',
|
||||
terminalContainerDisk: el.querySelector('#hm-terminal-container-disk')?.value || '51200',
|
||||
terminalContainerPersistent: !!el.querySelector('#hm-terminal-container-persistent')?.checked,
|
||||
}
|
||||
terminalSaving = true
|
||||
terminalError = null
|
||||
draw()
|
||||
try {
|
||||
const result = await api.hermesTerminalConfigSave(form)
|
||||
terminalValues = { ...TERMINAL_DEFAULTS, ...(result?.values || form) }
|
||||
await refreshRawAfterStructuredSave()
|
||||
const backup = result?.backup || ''
|
||||
toast({
|
||||
message: t('engine.hermesTerminalConfigSaveSuccess'),
|
||||
hint: backup ? t('engine.hermesConfigBackupHint', { path: backup }) : '',
|
||||
}, 'success')
|
||||
} catch (err) {
|
||||
terminalError = humanizeError(err, t('engine.hermesTerminalConfigSaveFailed') || 'Save terminal config failed')
|
||||
toast(terminalError, 'error')
|
||||
} finally {
|
||||
terminalSaving = false
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
draw()
|
||||
load()
|
||||
return el
|
||||
|
||||
@@ -521,6 +521,8 @@ export const api = {
|
||||
hermesStreamingConfigSave: (form) => invoke('hermes_streaming_config_save', { form }),
|
||||
hermesExecutionLimitsConfigRead: () => invoke('hermes_execution_limits_config_read'),
|
||||
hermesExecutionLimitsConfigSave: (form) => invoke('hermes_execution_limits_config_save', { form }),
|
||||
hermesTerminalConfigRead: () => invoke('hermes_terminal_config_read'),
|
||||
hermesTerminalConfigSave: (form) => invoke('hermes_terminal_config_save', { form }),
|
||||
hermesLazyDepsFeatures: () => cachedInvoke('hermes_lazy_deps_features', {}, 600000),
|
||||
hermesLazyDepsStatus: (features) => invoke('hermes_lazy_deps_status', { features }),
|
||||
hermesLazyDepsEnsure: (feature) => invoke('hermes_lazy_deps_ensure', { feature }),
|
||||
|
||||
@@ -498,6 +498,32 @@ export default {
|
||||
hermesGroupSessionsPerUser: _('群聊按成员隔离会话', 'Isolate group sessions per user', '群聊依成員隔離會話'),
|
||||
hermesThreadSessionsPerUser: _('线程也按成员隔离', 'Isolate thread sessions per user', '討論串也依成員隔離'),
|
||||
hermesSessionRuntimeFootnote: _('推荐保持群聊隔离开启。关闭后,同一群/频道会共用上下文和中断状态。', 'Keeping group isolation on is recommended. Turning it off shares context and interrupt state across the same group or channel.', '建議保持群聊隔離開啟。關閉後,同一群組/頻道會共用上下文和中斷狀態。'),
|
||||
hermesTerminalConfigTitle: _('终端执行', 'Terminal execution', '終端執行'),
|
||||
hermesTerminalConfigDesc: _('控制 Hermes 工具命令的执行环境、工作目录、超时和容器资源,避免长任务卡死或沙箱范围误配。', 'Control command execution backend, working directory, timeouts, and container resources to avoid stuck runs or sandbox misconfiguration.', '控制 Hermes 工具命令的執行環境、工作目錄、逾時和容器資源,避免長任務卡死或沙箱範圍誤配。'),
|
||||
hermesTerminalConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'),
|
||||
hermesTerminalConfigSave: _('保存终端配置', 'Save terminal settings', '儲存終端設定'),
|
||||
hermesTerminalConfigSaveSuccess: _('终端执行配置已保存,建议重启 Hermes Gateway 生效', 'Terminal execution settings saved. Restart Hermes Gateway to take effect.', '終端執行設定已儲存,建議重啟 Hermes Gateway 生效'),
|
||||
hermesTerminalConfigLoadFailed: _('加载终端执行配置失败', 'Load terminal execution settings failed', '載入終端執行設定失敗'),
|
||||
hermesTerminalConfigSaveFailed: _('保存终端执行配置失败', 'Save terminal execution settings failed', '儲存終端執行設定失敗'),
|
||||
hermesTerminalConfigBackend: _('执行后端', 'Execution backend', '執行後端'),
|
||||
hermesTerminalConfigBackend_local: _('本机', 'Local machine', '本機'),
|
||||
hermesTerminalConfigBackend_ssh: _('SSH 远程', 'SSH remote', 'SSH 遠端'),
|
||||
hermesTerminalConfigBackend_docker: _('Docker 容器', 'Docker container', 'Docker 容器'),
|
||||
hermesTerminalConfigBackend_singularity: _('Singularity / Apptainer', 'Singularity / Apptainer', 'Singularity / Apptainer'),
|
||||
hermesTerminalConfigBackend_modal: _('Modal 云沙箱', 'Modal cloud sandbox', 'Modal 雲端沙箱'),
|
||||
hermesTerminalConfigBackend_daytona: _('Daytona 云沙箱', 'Daytona cloud sandbox', 'Daytona 雲端沙箱'),
|
||||
hermesTerminalConfigBackend_vercel_sandbox: _('Vercel 沙箱', 'Vercel sandbox', 'Vercel 沙箱'),
|
||||
hermesTerminalConfigCwd: _('工作目录', 'Working directory', '工作目錄'),
|
||||
hermesTerminalConfigTimeout: _('命令超时秒数', 'Command timeout seconds', '命令逾時秒數'),
|
||||
hermesTerminalConfigLifetimeSeconds: _('沙箱生命周期秒数', 'Sandbox lifetime seconds', '沙箱生命週期秒數'),
|
||||
hermesTerminalConfigDockerMountCwd: _('Docker 挂载启动目录到 /workspace', 'Mount launch cwd into Docker /workspace', 'Docker 掛載啟動目錄到 /workspace'),
|
||||
hermesTerminalConfigDockerRunAsHostUser: _('Docker 使用宿主用户运行', 'Run Docker as host user', 'Docker 使用宿主使用者執行'),
|
||||
hermesTerminalConfigContainerPersistent: _('容器文件系统持久化', 'Persist container filesystem', '容器檔案系統持久化'),
|
||||
hermesTerminalConfigContainerTitle: _('容器资源限制', 'Container resource limits', '容器資源限制'),
|
||||
hermesTerminalConfigContainerCpu: _('CPU 核数', 'CPU cores', 'CPU 核心數'),
|
||||
hermesTerminalConfigContainerMemory: _('内存 MB', 'Memory MB', '記憶體 MB'),
|
||||
hermesTerminalConfigContainerDisk: _('磁盘 MB', 'Disk MB', '磁碟 MB'),
|
||||
hermesTerminalConfigFootnote: _('Docker 挂载启动目录会把宿主目录暴露给沙箱,仅在可信项目和无人值守任务中开启;SSH、镜像、环境变量等高级参数仍可在 raw YAML 中编辑。', 'Mounting the launch cwd exposes host files to the sandbox. Enable it only for trusted projects or unattended jobs. SSH, image, and env advanced fields remain editable in raw YAML.', 'Docker 掛載啟動目錄會把宿主目錄暴露給沙箱,僅在可信專案和無人值守任務中開啟;SSH、映像、環境變數等進階參數仍可在 raw YAML 中編輯。'),
|
||||
hermesStreamingConfigTitle: _('网关流式输出', 'Gateway streaming output', '閘道流式輸出'),
|
||||
hermesStreamingConfigDesc: _('控制 Hermes Gateway 回复时是否边生成边更新消息,以及消息刷新节奏。适合需要更快看到长回复进度的渠道。', 'Control whether Hermes Gateway updates messages while replies are generated, plus the refresh cadence. Useful when channels need quicker progress for long replies.', '控制 Hermes Gateway 回覆時是否邊生成邊更新訊息,以及訊息刷新節奏。適合需要更快看到長回覆進度的渠道。'),
|
||||
hermesStreamingConfigStatusReady: _('结构化配置', 'structured settings', '結構化設定'),
|
||||
|
||||
@@ -70,6 +70,24 @@ test('Hermes 配置页会暴露执行与委派限制结构化配置字段', () =
|
||||
}
|
||||
})
|
||||
|
||||
test('Hermes 配置页会暴露终端执行结构化配置字段', () => {
|
||||
for (const id of [
|
||||
'hm-terminal-save',
|
||||
'hm-terminal-backend',
|
||||
'hm-terminal-cwd',
|
||||
'hm-terminal-timeout',
|
||||
'hm-terminal-lifetime-seconds',
|
||||
'hm-terminal-docker-mount-cwd-to-workspace',
|
||||
'hm-terminal-docker-run-as-host-user',
|
||||
'hm-terminal-container-cpu',
|
||||
'hm-terminal-container-memory',
|
||||
'hm-terminal-container-disk',
|
||||
'hm-terminal-container-persistent',
|
||||
]) {
|
||||
assert.match(source, new RegExp(`id="${id}"`), `缺少 ${id}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('Hermes 配置页数值输入会保留 0 值显示', () => {
|
||||
assert.doesNotMatch(source, /String\(value \|\| ''\)/, 'esc(value) 不能把合法 0 渲染为空字符串')
|
||||
})
|
||||
@@ -79,7 +97,8 @@ test('Hermes 配置页新增结构化配置不会暴露翻译 key', () => {
|
||||
key.includes('ToolGuardrails') ||
|
||||
key.includes('MemoryConfig') ||
|
||||
key.includes('StreamingConfig') ||
|
||||
key.includes('ExecutionLimits')
|
||||
key.includes('ExecutionLimits') ||
|
||||
key.includes('TerminalConfig')
|
||||
)))
|
||||
|
||||
assert.ok(keys.size > 0, '应能提取新增结构化配置用到的 engine 翻译 key')
|
||||
|
||||
115
tests/hermes-terminal-config.test.js
Normal file
115
tests/hermes-terminal-config.test.js
Normal file
@@ -0,0 +1,115 @@
|
||||
import test from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
|
||||
import {
|
||||
buildHermesTerminalConfigValues,
|
||||
mergeHermesTerminalConfig,
|
||||
} from '../scripts/dev-api.js'
|
||||
|
||||
test('Hermes 终端执行配置读取会提供上游默认值', () => {
|
||||
const values = buildHermesTerminalConfigValues({})
|
||||
|
||||
assert.deepEqual(values, {
|
||||
terminalBackend: 'local',
|
||||
terminalCwd: '.',
|
||||
terminalTimeout: 180,
|
||||
terminalLifetimeSeconds: 300,
|
||||
terminalDockerMountCwdToWorkspace: false,
|
||||
terminalDockerRunAsHostUser: false,
|
||||
terminalContainerCpu: 1,
|
||||
terminalContainerMemory: 5120,
|
||||
terminalContainerDisk: 51200,
|
||||
terminalContainerPersistent: true,
|
||||
})
|
||||
})
|
||||
|
||||
test('Hermes 终端执行配置读取会回显 YAML 字段', () => {
|
||||
const values = buildHermesTerminalConfigValues({
|
||||
terminal: {
|
||||
backend: 'docker',
|
||||
cwd: '/workspace',
|
||||
timeout: 600,
|
||||
lifetime_seconds: 1800,
|
||||
docker_mount_cwd_to_workspace: true,
|
||||
docker_run_as_host_user: true,
|
||||
container_cpu: 4,
|
||||
container_memory: 8192,
|
||||
container_disk: 102400,
|
||||
container_persistent: false,
|
||||
},
|
||||
})
|
||||
|
||||
assert.equal(values.terminalBackend, 'docker')
|
||||
assert.equal(values.terminalCwd, '/workspace')
|
||||
assert.equal(values.terminalTimeout, 600)
|
||||
assert.equal(values.terminalLifetimeSeconds, 1800)
|
||||
assert.equal(values.terminalDockerMountCwdToWorkspace, true)
|
||||
assert.equal(values.terminalDockerRunAsHostUser, true)
|
||||
assert.equal(values.terminalContainerCpu, 4)
|
||||
assert.equal(values.terminalContainerMemory, 8192)
|
||||
assert.equal(values.terminalContainerDisk, 102400)
|
||||
assert.equal(values.terminalContainerPersistent, false)
|
||||
})
|
||||
|
||||
test('Hermes 终端执行配置保存会保留未知字段并写入上游结构', () => {
|
||||
const next = mergeHermesTerminalConfig({
|
||||
model: { provider: 'anthropic' },
|
||||
terminal: {
|
||||
backend: 'local',
|
||||
docker_image: 'custom/python-node',
|
||||
docker_forward_env: ['GITHUB_TOKEN'],
|
||||
custom_flag: 'keep-terminal',
|
||||
},
|
||||
streaming: { enabled: true },
|
||||
}, {
|
||||
terminalBackend: 'docker',
|
||||
terminalCwd: '/workspace',
|
||||
terminalTimeout: '900',
|
||||
terminalLifetimeSeconds: '1200',
|
||||
terminalDockerMountCwdToWorkspace: true,
|
||||
terminalDockerRunAsHostUser: true,
|
||||
terminalContainerCpu: '2',
|
||||
terminalContainerMemory: '6144',
|
||||
terminalContainerDisk: '20480',
|
||||
terminalContainerPersistent: false,
|
||||
})
|
||||
|
||||
assert.deepEqual(next.model, { provider: 'anthropic' })
|
||||
assert.deepEqual(next.streaming, { enabled: true })
|
||||
assert.equal(next.terminal.backend, 'docker')
|
||||
assert.equal(next.terminal.cwd, '/workspace')
|
||||
assert.equal(next.terminal.timeout, 900)
|
||||
assert.equal(next.terminal.lifetime_seconds, 1200)
|
||||
assert.equal(next.terminal.docker_mount_cwd_to_workspace, true)
|
||||
assert.equal(next.terminal.docker_run_as_host_user, true)
|
||||
assert.equal(next.terminal.container_cpu, 2)
|
||||
assert.equal(next.terminal.container_memory, 6144)
|
||||
assert.equal(next.terminal.container_disk, 20480)
|
||||
assert.equal(next.terminal.container_persistent, false)
|
||||
assert.equal(next.terminal.docker_image, 'custom/python-node')
|
||||
assert.deepEqual(next.terminal.docker_forward_env, ['GITHUB_TOKEN'])
|
||||
assert.equal(next.terminal.custom_flag, 'keep-terminal')
|
||||
})
|
||||
|
||||
test('Hermes 终端执行配置保存会拒绝非法后端和越界值', () => {
|
||||
assert.throws(
|
||||
() => mergeHermesTerminalConfig({}, { terminalBackend: 'unsafe' }),
|
||||
/terminal\.backend/,
|
||||
)
|
||||
assert.throws(
|
||||
() => mergeHermesTerminalConfig({}, { terminalTimeout: '0' }),
|
||||
/terminal\.timeout/,
|
||||
)
|
||||
assert.throws(
|
||||
() => mergeHermesTerminalConfig({}, { terminalLifetimeSeconds: '-1' }),
|
||||
/terminal\.lifetime_seconds/,
|
||||
)
|
||||
assert.throws(
|
||||
() => mergeHermesTerminalConfig({}, { terminalContainerCpu: '0' }),
|
||||
/terminal\.container_cpu/,
|
||||
)
|
||||
assert.throws(
|
||||
() => mergeHermesTerminalConfig({}, { terminalContainerMemory: '127' }),
|
||||
/terminal\.container_memory/,
|
||||
)
|
||||
})
|
||||
Reference in New Issue
Block a user