diff --git a/scripts/dev-api.js b/scripts/dev-api.js index b28535b..4ab93b6 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -3560,6 +3560,22 @@ function normalizeHermesProviderRoutingList(value, key) { return normalized } +function normalizeHermesEnvNameList(value, key) { + const seen = new Set() + const normalized = [] + for (const item of normalizeHermesMultilineList(value)) { + const name = String(item ?? '').trim() + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + throw new Error(`${key} 只能填写环境变量名,每行一个,例如 GITHUB_TOKEN`) + } + if (!seen.has(name)) { + seen.add(name) + normalized.push(name) + } + } + return normalized +} + function normalizeHermesAuxiliaryProvider(value, key, strict = false) { const provider = String(value ?? '').trim().toLowerCase() || 'auto' if (HERMES_AUXILIARY_PROVIDERS.has(provider)) return provider @@ -5163,6 +5179,7 @@ export function buildHermesTerminalConfigValues(config = {}) { terminalSingularityImage: typeof terminal.singularity_image === 'string' ? terminal.singularity_image.trim() : '', terminalModalImage: typeof terminal.modal_image === 'string' ? terminal.modal_image.trim() : '', 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() : '', terminalSshUser: typeof terminal.ssh_user === 'string' ? terminal.ssh_user.trim() : '', terminalSshPort: parseHermesInteger(terminal.ssh_port, 'terminal.ssh_port', 22, 1, 65535, false), @@ -5196,6 +5213,9 @@ export function mergeHermesTerminalConfig(config = {}, form = {}) { if (image) terminal[yamlKey] = image else delete terminal[yamlKey] } + const dockerForwardEnv = normalizeHermesEnvNameList(Object.hasOwn(form, 'terminalDockerForwardEnv') ? form.terminalDockerForwardEnv : currentValues.terminalDockerForwardEnv, 'terminal.docker_forward_env') + if (dockerForwardEnv.length) terminal.docker_forward_env = dockerForwardEnv + else delete terminal.docker_forward_env for (const [formKey, yamlKey] of [ ['terminalSshHost', 'ssh_host'], ['terminalSshUser', 'ssh_user'], diff --git a/src-tauri/src/commands/hermes.rs b/src-tauri/src/commands/hermes.rs index 26a4c1e..e439e7d 100644 --- a/src-tauri/src/commands/hermes.rs +++ b/src-tauri/src/commands/hermes.rs @@ -2340,6 +2340,31 @@ fn normalize_hermes_provider_routing_list( Ok(values) } +fn normalize_hermes_env_name_list(raw: Option, key: &str) -> Result, String> { + let mut values = Vec::new(); + for item in normalize_hermes_multiline_list(raw) { + let name = item.trim().to_string(); + if name.is_empty() { + continue; + } + let mut chars = name.chars(); + let valid_first = chars + .next() + .map(|ch| ch.is_ascii_alphabetic() || ch == '_') + .unwrap_or(false); + let valid_rest = chars.all(|ch| ch.is_ascii_alphanumeric() || ch == '_'); + if !valid_first || !valid_rest { + return Err(format!( + "{key} 只能填写环境变量名,每行一个,例如 GITHUB_TOKEN" + )); + } + if !values.contains(&name) { + values.push(name); + } + } + Ok(values) +} + fn normalize_hermes_auxiliary_provider( value: Option, key: &str, @@ -7867,6 +7892,9 @@ fn build_hermes_terminal_config_values(config: &serde_yaml::Value) -> Value { let terminal_singularity_image = terminal_string("singularity_image"); let terminal_modal_image = terminal_string("modal_image"); 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")) + .unwrap_or_default(); let terminal_ssh_host = terminal_string("ssh_host"); let terminal_ssh_user = terminal_string("ssh_user"); let terminal_ssh_port = terminal @@ -7897,6 +7925,7 @@ fn build_hermes_terminal_config_values(config: &serde_yaml::Value) -> Value { "terminalSingularityImage": terminal_singularity_image, "terminalModalImage": terminal_modal_image, "terminalDaytonaImage": terminal_daytona_image, + "terminalDockerForwardEnv": terminal_docker_forward_env, "terminalSshHost": terminal_ssh_host, "terminalSshUser": terminal_ssh_user, "terminalSshPort": terminal_ssh_port, @@ -8004,6 +8033,14 @@ fn merge_hermes_terminal_config( .unwrap_or_default() .trim() .to_string(); + let terminal_docker_forward_env = normalize_hermes_env_name_list( + form_string(form, "terminalDockerForwardEnv").or_else(|| { + current["terminalDockerForwardEnv"] + .as_str() + .map(ToString::to_string) + }), + "terminal.docker_forward_env", + )?; let terminal_ssh_host = form_string(form, "terminalSshHost") .or_else(|| current["terminalSshHost"].as_str().map(ToString::to_string)) .unwrap_or_default() @@ -8097,6 +8134,19 @@ fn merge_hermes_terminal_config( set_optional_yaml_string(terminal, "singularity_image", terminal_singularity_image); set_optional_yaml_string(terminal, "modal_image", terminal_modal_image); set_optional_yaml_string(terminal, "daytona_image", terminal_daytona_image); + if terminal_docker_forward_env.is_empty() { + terminal.remove(yaml_key("docker_forward_env")); + } else { + terminal.insert( + yaml_key("docker_forward_env"), + serde_yaml::Value::Sequence( + terminal_docker_forward_env + .into_iter() + .map(serde_yaml::Value::String) + .collect(), + ), + ); + } set_optional_yaml_string(terminal, "ssh_host", terminal_ssh_host); set_optional_yaml_string(terminal, "ssh_user", terminal_ssh_user); terminal.insert( @@ -16625,6 +16675,7 @@ mod hermes_terminal_config_tests { assert_eq!(values["terminalSingularityImage"], ""); assert_eq!(values["terminalModalImage"], ""); assert_eq!(values["terminalDaytonaImage"], ""); + assert_eq!(values["terminalDockerForwardEnv"], ""); assert_eq!(values["terminalSshHost"], ""); assert_eq!(values["terminalSshUser"], ""); assert_eq!(values["terminalSshPort"], 22); @@ -16643,6 +16694,9 @@ terminal: docker_mount_cwd_to_workspace: true docker_run_as_host_user: true docker_image: nikolaik/python-nodejs:python3.11-nodejs20 + docker_forward_env: + - GITHUB_TOKEN + - NPM_TOKEN singularity_image: docker://nikolaik/python-nodejs:python3.11-nodejs20 modal_image: python:3.12 daytona_image: ubuntu:24.04 @@ -16668,6 +16722,10 @@ terminal: values["terminalDockerImage"], "nikolaik/python-nodejs:python3.11-nodejs20" ); + assert_eq!( + values["terminalDockerForwardEnv"], + "GITHUB_TOKEN\nNPM_TOKEN" + ); assert_eq!( values["terminalSingularityImage"], "docker://nikolaik/python-nodejs:python3.11-nodejs20" @@ -16694,7 +16752,7 @@ terminal: backend: local docker_image: custom/python-node docker_forward_env: - - GITHUB_TOKEN + - OLD_TOKEN custom_flag: keep-terminal streaming: enabled: true @@ -16712,6 +16770,7 @@ streaming: "terminalDockerMountCwdToWorkspace": true, "terminalDockerRunAsHostUser": true, "terminalDockerImage": "nikolaik/python-nodejs:python3.12-nodejs22", + "terminalDockerForwardEnv": "GITHUB_TOKEN\nNPM_TOKEN\nGITHUB_TOKEN", "terminalSingularityImage": "docker://ubuntu:24.04", "terminalModalImage": "debian:bookworm", "terminalDaytonaImage": "ubuntu:22.04", @@ -16778,6 +16837,44 @@ streaming: config["terminal"]["docker_forward_env"][0].as_str(), Some("GITHUB_TOKEN") ); + assert_eq!( + config["terminal"]["docker_forward_env"][1].as_str(), + Some("NPM_TOKEN") + ); + assert_eq!( + config["terminal"]["docker_forward_env"] + .as_sequence() + .unwrap() + .len(), + 2 + ); + assert_eq!( + config["terminal"]["custom_flag"].as_str(), + Some("keep-terminal") + ); + } + + #[test] + fn merge_terminal_config_removes_empty_docker_forward_env() { + let mut config: serde_yaml::Value = serde_yaml::from_str( + r#" +terminal: + docker_forward_env: + - GITHUB_TOKEN + custom_flag: keep-terminal +"#, + ) + .unwrap(); + + merge_hermes_terminal_config( + &mut config, + &json!({ + "terminalDockerForwardEnv": " \n", + }), + ) + .unwrap(); + + assert!(config["terminal"]["docker_forward_env"].is_null()); assert_eq!( config["terminal"]["custom_flag"].as_str(), Some("keep-terminal") @@ -16881,6 +16978,12 @@ terminal: let err = merge_hermes_terminal_config(&mut config, &json!({ "terminalSshPort": 65536 })) .unwrap_err(); assert!(err.contains("terminal.ssh_port")); + let err = merge_hermes_terminal_config( + &mut config, + &json!({ "terminalDockerForwardEnv": "GOOD_TOKEN\nBAD TOKEN" }), + ) + .unwrap_err(); + assert!(err.contains("terminal.docker_forward_env")); } } diff --git a/src/engines/hermes/pages/config.js b/src/engines/hermes/pages/config.js index 6b6831c..8575044 100644 --- a/src/engines/hermes/pages/config.js +++ b/src/engines/hermes/pages/config.js @@ -266,6 +266,7 @@ const TERMINAL_DEFAULTS = { terminalSingularityImage: '', terminalModalImage: '', terminalDaytonaImage: '', + terminalDockerForwardEnv: '', terminalSshHost: '', terminalSshUser: '', terminalSshPort: 22, @@ -2022,6 +2023,10 @@ export function render() { ${t('engine.hermesTerminalConfigDockerImage')} +