mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-11 10:00:04 +08:00
feat(hermes): add .env editor page for unmanaged env vars (Step 4)
Users may need to configure custom environment variables for Hermes
(e.g. `TAVILY_API_KEY` for the tavily skill, `HTTP_PROXY`, SKILL_*
settings). Previously the only way to set these was to hand-edit
~/.hermes/.env, which risks clobbering the provider keys that
ClawPanel writes through configure_hermes.
This patch adds a dedicated editor UI backed by three new Tauri
commands that refuse to touch managed keys.
Backend (src-tauri/src/commands/hermes.rs):
- `hermes_env_read_unmanaged` — returns every KEY=VALUE pair whose key
is NOT in `hermes_providers::all_managed_env_keys()` (so provider
API keys, base URLs, `GATEWAY_ALLOW_ALL_USERS`, `API_SERVER_KEY`
stay hidden). Preserves file order, dedups.
- `hermes_env_set(key, value)` — validates key against `^[A-Z0-9_]+$`,
refuses managed keys, updates first occurrence or appends,
preserves comments/blanks.
- `hermes_env_delete(key)` — refuses managed keys, removes first
matching line, preserves other structure.
All three commands registered in `src-tauri/src/lib.rs`.
Frontend:
- New page `src/engines/hermes/pages/env-editor.js`:
- Header with "back to dashboard" link and warning banner listing
which keys are managed by ClawPanel.
- Table with Key / Value / Actions columns.
- Inline edit mode per row (save / cancel).
- "Add variable" button for new entries.
- Value column masks long secrets (`sk-a…xyz9`) so glances don't
leak credentials.
- Toast feedback on save / delete / validation errors.
- Inline Chinese copy (TODO: wire up i18n when the locales module
lands).
- Route registered at `/h/env` in `src/engines/hermes/index.js`.
- Dashboard "Model config" section now has a subtle link
".env 高级编辑 →" pointing to the new page.
API wiring:
- `src/lib/tauri-api.js`: added `hermesEnvReadUnmanaged`,
`hermesEnvSet`, `hermesEnvDelete`.
- `scripts/dev-api.js`: mirrors the three commands in Web mode with
a duplicated managed-key list (keep in sync with Rust's
`hermes_providers::all_managed_env_keys` as new providers land).
Verified: cargo fmt / cargo clippy -D warnings / cargo test / npm run
build all green. Dashboard chunk unchanged (24.30 kB); new env-editor
chunk is ~7 kB gzip.
This commit is contained in:
@@ -3138,3 +3138,191 @@ pub async fn hermes_memory_write(
|
||||
std::fs::write(&file_path, &content).map_err(|e| format!("Failed to write memory: {e}"))?;
|
||||
Ok("ok".into())
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// .env editor commands (Step 4 / G6)
|
||||
//
|
||||
// Users may need to set custom environment variables for Hermes (e.g.
|
||||
// `TAVILY_API_KEY` for the tavily skill, `HTTP_PROXY`, etc.). These keys
|
||||
// live in ~/.hermes/.env alongside the ClawPanel-managed provider keys.
|
||||
//
|
||||
// The three commands below:
|
||||
// * `hermes_env_read_unmanaged` — returns every key in .env that is NOT
|
||||
// managed by ClawPanel (i.e. not in `hermes_providers::all_managed_env_keys`)
|
||||
// * `hermes_env_set` — writes or updates an unmanaged key
|
||||
// * `hermes_env_delete` — removes an unmanaged key
|
||||
//
|
||||
// All three refuse to touch `all_managed_env_keys` to prevent users from
|
||||
// accidentally clobbering provider keys from the editor UI (those should
|
||||
// be configured via the setup page / configure_hermes).
|
||||
// ============================================================================
|
||||
|
||||
/// Lenient .env parser shared by the three commands below.
|
||||
/// Returns a Vec of (key, value, original_line_index) for every `KEY=VALUE`
|
||||
/// pair. Comments and blanks are preserved by line index but not returned.
|
||||
fn parse_env_file_lines(raw: &str) -> Vec<(String, String, usize)> {
|
||||
let mut out = Vec::new();
|
||||
for (i, line) in raw.lines().enumerate() {
|
||||
let t = line.trim();
|
||||
if t.is_empty() || t.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if let Some((k, v)) = t.split_once('=') {
|
||||
let k = k.trim().to_string();
|
||||
if k.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push((k, v.to_string(), i));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Return every non-managed `KEY=VALUE` pair from ~/.hermes/.env.
|
||||
///
|
||||
/// Output is ordered by the order of appearance in the file. Managed keys
|
||||
/// (provider API keys, base URLs, `GATEWAY_ALLOW_ALL_USERS`, `API_SERVER_KEY`)
|
||||
/// are filtered out — those are surfaced separately in the config UI.
|
||||
#[tauri::command]
|
||||
pub fn hermes_env_read_unmanaged() -> Result<Vec<(String, String)>, String> {
|
||||
use super::hermes_providers;
|
||||
|
||||
let env_path = hermes_home().join(".env");
|
||||
if !env_path.exists() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let raw =
|
||||
std::fs::read_to_string(&env_path).map_err(|e| format!("Failed to read .env: {e}"))?;
|
||||
|
||||
let managed = hermes_providers::all_managed_env_keys();
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
let mut seen = std::collections::HashSet::<String>::new();
|
||||
for (k, v, _) in parse_env_file_lines(&raw) {
|
||||
if managed.contains(&k.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if seen.insert(k.clone()) {
|
||||
out.push((k, v));
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Write or update a single unmanaged env var in ~/.hermes/.env.
|
||||
///
|
||||
/// Refuses to write keys in `hermes_providers::all_managed_env_keys`.
|
||||
/// Creates the file (and parent dir) if missing.
|
||||
#[tauri::command]
|
||||
pub fn hermes_env_set(key: String, value: String) -> Result<(), String> {
|
||||
use super::hermes_providers;
|
||||
|
||||
let key = key.trim().to_string();
|
||||
if key.is_empty() {
|
||||
return Err("Key cannot be empty".into());
|
||||
}
|
||||
// Basic sanity: env var keys are typically A-Z0-9_
|
||||
if !key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
|
||||
return Err(format!(
|
||||
"Invalid env var key '{key}': only [A-Z0-9_] are allowed"
|
||||
));
|
||||
}
|
||||
let managed = hermes_providers::all_managed_env_keys();
|
||||
if managed.contains(&key.as_str()) {
|
||||
return Err(format!(
|
||||
"'{key}' is managed by ClawPanel; please configure it via the provider setup page"
|
||||
));
|
||||
}
|
||||
|
||||
let env_path = hermes_home().join(".env");
|
||||
if let Some(parent) = env_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| format!("Failed to create .hermes dir: {e}"))?;
|
||||
}
|
||||
|
||||
let raw = if env_path.exists() {
|
||||
std::fs::read_to_string(&env_path).map_err(|e| format!("Failed to read .env: {e}"))?
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
|
||||
// Preserve file structure: if the key already exists, update the first
|
||||
// occurrence and leave the rest (which would be dead code anyway for
|
||||
// dotenv loaders) alone. Otherwise append a new line.
|
||||
let lines: Vec<&str> = raw.lines().collect();
|
||||
let mut out: Vec<String> = Vec::with_capacity(lines.len() + 1);
|
||||
let mut replaced = false;
|
||||
for line in lines.iter() {
|
||||
let t = line.trim();
|
||||
if t.starts_with('#') || t.is_empty() {
|
||||
out.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
if let Some((k, _)) = t.split_once('=') {
|
||||
if k.trim() == key && !replaced {
|
||||
out.push(format!("{key}={value}"));
|
||||
replaced = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
out.push(line.to_string());
|
||||
}
|
||||
if !replaced {
|
||||
out.push(format!("{key}={value}"));
|
||||
}
|
||||
let mut content = out.join("\n");
|
||||
if !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
}
|
||||
std::fs::write(&env_path, content).map_err(|e| format!("Failed to write .env: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Remove an unmanaged env var from ~/.hermes/.env.
|
||||
///
|
||||
/// Refuses to delete keys in `hermes_providers::all_managed_env_keys`.
|
||||
/// No-op if the key doesn't exist.
|
||||
#[tauri::command]
|
||||
pub fn hermes_env_delete(key: String) -> Result<(), String> {
|
||||
use super::hermes_providers;
|
||||
|
||||
let key = key.trim().to_string();
|
||||
if key.is_empty() {
|
||||
return Err("Key cannot be empty".into());
|
||||
}
|
||||
let managed = hermes_providers::all_managed_env_keys();
|
||||
if managed.contains(&key.as_str()) {
|
||||
return Err(format!(
|
||||
"'{key}' is managed by ClawPanel; please configure it via the provider setup page"
|
||||
));
|
||||
}
|
||||
|
||||
let env_path = hermes_home().join(".env");
|
||||
if !env_path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
let raw =
|
||||
std::fs::read_to_string(&env_path).map_err(|e| format!("Failed to read .env: {e}"))?;
|
||||
|
||||
let lines: Vec<&str> = raw.lines().collect();
|
||||
let mut out: Vec<String> = Vec::with_capacity(lines.len());
|
||||
for line in lines.iter() {
|
||||
let t = line.trim();
|
||||
if t.starts_with('#') || t.is_empty() {
|
||||
out.push(line.to_string());
|
||||
continue;
|
||||
}
|
||||
if let Some((k, _)) = t.split_once('=') {
|
||||
if k.trim() == key {
|
||||
continue; // drop
|
||||
}
|
||||
}
|
||||
out.push(line.to_string());
|
||||
}
|
||||
let mut content = out.join("\n");
|
||||
if !content.ends_with('\n') {
|
||||
content.push('\n');
|
||||
}
|
||||
std::fs::write(&env_path, content).map_err(|e| format!("Failed to write .env: {e}"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -231,6 +231,9 @@ pub fn run() {
|
||||
hermes::hermes_update_model,
|
||||
hermes::hermes_detect_environments,
|
||||
hermes_providers::hermes_list_providers,
|
||||
hermes::hermes_env_read_unmanaged,
|
||||
hermes::hermes_env_set,
|
||||
hermes::hermes_env_delete,
|
||||
hermes::hermes_set_gateway_url,
|
||||
hermes::update_hermes,
|
||||
hermes::uninstall_hermes,
|
||||
|
||||
Reference in New Issue
Block a user