Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 109 additions & 2 deletions crates/tui/src/sandbox/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, String>) -> 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
}
Expand Down Expand Up @@ -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.
Expand Down
107 changes: 107 additions & 0 deletions crates/tui/src/tools/shell/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading