diff --git a/crates/tui/src/sandbox/mod.rs b/crates/tui/src/sandbox/mod.rs index 2ba60080c8..bbb424a4c2 100644 --- a/crates/tui/src/sandbox/mod.rs +++ b/crates/tui/src/sandbox/mod.rs @@ -213,14 +213,21 @@ impl CommandSpec { self } - /// Add environment variables for this command. + /// Add environment variables without discarding shell defaults. pub fn with_env(mut self, env: HashMap) -> Self { - self.env = env; + for (key, value) in env { + self = self.with_env_var(&key, &value); + } self } /// Add a single environment variable. pub fn with_env_var(mut self, key: &str, value: &str) -> Self { + // Windows environment names are case-insensitive. Remove an earlier + // spelling so the override cannot depend on HashMap iteration order. + #[cfg(windows)] + self.env + .retain(|existing, _| !existing.eq_ignore_ascii_case(key)); self.env.insert(key.to_string(), value.to_string()); self } @@ -948,6 +955,106 @@ mod tests { ); } + #[test] + fn command_spec_env_overlays_preserve_existing_values() { + let spec = CommandSpec::program("test", vec![], PathBuf::from("."), Duration::from_secs(5)) + .with_env_var("FIRST", "original") + .with_env(HashMap::new()) + .with_env(HashMap::from([("SECOND".to_string(), "added".to_string())])); + assert_eq!(spec.env.get("FIRST").map(String::as_str), Some("original")); + assert_eq!(spec.env.get("SECOND").map(String::as_str), Some("added")); + let spec = spec.with_env(HashMap::from([( + "FIRST".to_string(), + "override".to_string(), + )])); + assert_eq!(spec.env.get("FIRST").map(String::as_str), Some("override")); + } + + #[cfg(windows)] + #[tokio::test] + async fn forkguard_windows_shell_env_overlays_keep_python_stdio_utf8() { + use crate::shell_dispatcher::ShellKind; + + let _lock = crate::test_support::lock_test_env(); + let _legacy_python = crate::test_support::EnvVarGuard::set("PYTHONIOENCODING", "gbk"); + let kind = crate::shell_dispatcher::global_dispatcher().kind(); + let command = if kind.is_powershell() { + "Write-Output $env:PYTHONIOENCODING; Write-Output '中文-output'" + } else if matches!(kind, ShellKind::Cmd) { + "echo %PYTHONIOENCODING%& echo 中文-output" + } else { + "printf '%s\\n' \"$PYTHONIOENCODING\" '中文-output'" + }; + for encoded in [false, true] { + if encoded && !kind.is_powershell() { + continue; + } + for extra_env in [ + HashMap::new(), + HashMap::from([( + "CODEWHALE_ENCODING_PROBE".to_string(), + "present".to_string(), + )]), + ] { + let cwd = std::env::temp_dir(); + let timeout = Duration::from_secs(30); + let spec = if encoded { + CommandSpec::powershell_encoded_shell(command, cwd, timeout) + .expect("Windows PowerShell encoded form") + } else { + CommandSpec::shell(command, cwd, timeout) + } + .with_env(extra_env) + .with_policy(SandboxPolicy::DangerFullAccess); + let exec = SandboxManager::new().prepare(&spec); + let mut cmd = tokio::process::Command::new(exec.program()); + cmd.args(exec.args()) + .current_dir(&spec.cwd) + .kill_on_drop(true); + crate::utils::suppress_console_window(cmd.as_std_mut()); + crate::child_env::apply_to_tokio_command( + &mut cmd, + crate::child_env::string_map_env(&exec.env), + ); + let output = tokio::time::timeout(spec.timeout, cmd.output()) + .await + .expect("shell environment probe timed out") + .expect("run shell with overlaid environment"); + assert!(output.status.success(), "{output:?}"); + let stdout = String::from_utf8(output.stdout).expect("UTF-8 shell output"); + assert!(stdout.lines().any(|line| line == "utf-8"), "{stdout:?}"); + assert!(stdout.contains("中文-output"), "{stdout:?}"); + } + } + } + + #[cfg(windows)] + #[test] + fn windows_shell_env_explicit_override_is_case_insensitive() { + let spec = CommandSpec::shell( + "Write-Output 'probe'", + std::env::temp_dir(), + Duration::from_secs(5), + ) + .with_env(HashMap::from([( + "pythonioencoding".to_string(), + "gbk".to_string(), + )])); + let matches: Vec<_> = spec + .env + .iter() + .filter(|(key, _)| key.eq_ignore_ascii_case("PYTHONIOENCODING")) + .collect(); + assert_eq!(matches.len(), 1); + assert_eq!(matches[0].1, "gbk"); + let spec = spec.with_env_var("PYTHONIOENCODING", "utf-8"); + assert_eq!( + spec.env.get("PYTHONIOENCODING").map(String::as_str), + Some("utf-8") + ); + assert!(!spec.env.contains_key("pythonioencoding")); + } + // The UTF-8 output contract keys on the PowerShell family: a custom // PowerShell path is accepted by `is_powershell`, so it must get the prefix // on both the normal shell spec and the encoded fallback. diff --git a/crates/tui/src/tools/shell/tests.rs b/crates/tui/src/tools/shell/tests.rs index 6894081aaf..3fd3ede155 100644 --- a/crates/tui/src/tools/shell/tests.rs +++ b/crates/tui/src/tools/shell/tests.rs @@ -210,6 +210,113 @@ async fn lowercase_bash_returns_one_ordered_stream() { assert_eq!(result.content, "out-1err-2out-3"); } +#[cfg(windows)] +fn windows_python_stdio_fixture() -> (tempfile::TempDir, String) { + assert!( + crate::shell_dispatcher::global_dispatcher() + .kind() + .is_powershell(), + "requires a PowerShell dispatcher; run from PowerShell or cmd with SHELL unset" + ); + let workspace = tempdir().expect("workspace"); + let program = std::env::var_os("CODEWHALE_TEST_PYTHON") + .unwrap_or_else(|| std::ffi::OsString::from("python")); + let mut probe = std::process::Command::new(program); + crate::utils::suppress_console_window(&mut probe); + let resolved = probe + .args(["-c", "import sys; print(sys.executable)"]) + .env("PYTHONIOENCODING", "utf-8") + .output() + .expect("native Python on PATH for this opt-in regression"); + assert!( + resolved.status.success(), + "Python resolution failed: {resolved:?}" + ); + let python = PathBuf::from( + String::from_utf8(resolved.stdout) + .expect("UTF-8 Python path") + .trim(), + ); + let script = workspace.path().join("verify.py"); + std::fs::write( + &script, + concat!( + "import sys\n", + "sys.stdout.write('x' * 65536 + '\\n')\n", + "print('STDOUT-\\u5927\\u5c0f\\u4e0d\\u5339\\u914d: 0 []', flush=True)\n", + "print('STDERR-\\u6821\\u9a8c\\u901a\\u8fc7: 0 GB', file=sys.stderr, flush=True)\n", + ), + ) + .expect("write Python output fixture"); + let quote = |path: &Path| path.to_string_lossy().replace('\'', "''"); + let command = format!( + "& '{}' '{}'; Write-Output 'PowerShell-中文正常'", + quote(&python), + quote(&script) + ); + (workspace, command) +} + +#[cfg(windows)] +fn assert_windows_python_stdio(output: &str) { + assert!( + output.contains("STDOUT-大小不匹配: 0 []"), + "Python stdout missing or garbled: {output}" + ); + assert!( + output.contains("STDERR-校验通过: 0 GB"), + "Python stderr missing or garbled: {output}" + ); + assert!( + output.contains("PowerShell-中文正常"), + "PowerShell output missing or garbled: {output}" + ); + assert!( + !output.contains('\u{fffd}'), + "output contains replacement characters" + ); +} + +#[cfg(windows)] +#[tokio::test] +#[ignore = "requires native Windows Python on PATH and PowerShell"] +async fn forkguard_windows_python_stdio_lowercase_bash() { + let _lock = crate::test_support::lock_test_env(); + let _legacy_python = crate::test_support::EnvVarGuard::set("PYTHONIOENCODING", "gbk"); + let (workspace, command) = windows_python_stdio_fixture(); + let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", workspace.path()); + let context = ToolContext::new(workspace.path()); + let result = LowercaseBashTool + .execute(json!({"command": command}), &context) + .await + .expect("execute actual lowercase bash tool"); + assert!(result.success, "{}", result.content); + assert_windows_python_stdio(&result.content); +} + +#[cfg(windows)] +#[test] +#[ignore = "requires native Windows Python on PATH and PowerShell"] +fn forkguard_windows_python_stdio_background_shell() { + let _lock = crate::test_support::lock_test_env(); + let _legacy_python = crate::test_support::EnvVarGuard::set("PYTHONIOENCODING", "gbk"); + let (workspace, command) = windows_python_stdio_fixture(); + let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", workspace.path()); + for extra_env in [ + HashMap::new(), + HashMap::from([("ENCODING_HOOK_PROBE".to_string(), "present".to_string())]), + ] { + let mut manager = ShellManager::new(workspace.path().to_path_buf()); + let started = manager + .execute_with_options_env(&command, None, 30_000, true, None, false, None, extra_env) + .expect("start real background shell"); + let task_id = started.task_id.expect("background task id"); + let result = wait_for_completed_shell(&mut manager, &task_id); + assert_eq!(result.status, ShellStatus::Completed, "{result:?}"); + assert_windows_python_stdio(&format!("{}\n{}", result.stdout, result.stderr)); + } +} + #[cfg(unix)] #[tokio::test] async fn lowercase_bash_keeps_raw_command_under_readonly_policy() {