From 0e8f2b11e8eee2902fdf8864906e96c3580bae33 Mon Sep 17 00:00:00 2001 From: Helios Agent Date: Tue, 3 Mar 2026 14:58:11 +0100 Subject: [PATCH] fix(client): strip shell.rs to bare minimum - no chcp, no wrapper, just cmd /D /C --- crates/client/src/shell.rs | 126 ++++++++----------------------------- 1 file changed, 27 insertions(+), 99 deletions(-) diff --git a/crates/client/src/shell.rs b/crates/client/src/shell.rs index 517ee1f..c5c5969 100644 --- a/crates/client/src/shell.rs +++ b/crates/client/src/shell.rs @@ -1,123 +1,51 @@ /// Shell execution — each command runs in its own fresh process. -/// This means no persistent state between commands (no cd carry-over), -/// but it is rock-solid: a crashed command never affects future commands. -/// -/// Output bytes are decoded with `from_utf8_lossy` so no encoding mismatch -/// can ever return an error. +/// No persistent state, no sentinel logic, no encoding tricks. +/// stdout/stderr are captured as raw bytes and decoded with from_utf8_lossy. use std::time::Duration; use tokio::process::Command; const TIMEOUT_MS: u64 = 30_000; -pub struct PersistentShell { - /// On Windows we keep track of the current working directory so callers - /// can still do `cd` and have it persist across commands. - #[cfg(windows)] - pub cwd: Option, -} +pub struct PersistentShell; impl PersistentShell { - pub fn new() -> Self { - Self { - #[cfg(windows)] - cwd: None, - } - } + pub fn new() -> Self { Self } - /// Run `command` in a fresh child process. - /// Always returns `Ok`; errors are encoded in the `String` fields so the - /// caller can forward them to the relay server. pub async fn run(&mut self, command: &str) -> Result<(String, String, i32), String> { - let timeout = Duration::from_millis(TIMEOUT_MS); - #[cfg(windows)] - let result = self.run_windows(command, timeout).await; - #[cfg(not(windows))] - let result = run_unix(command, timeout).await; - - result - } - - #[cfg(windows)] - async fn run_windows(&mut self, command: &str, timeout: Duration) -> Result<(String, String, i32), String> { - // Detect `cd ` — keep the new directory for subsequent commands. - let trimmed = command.trim(); - if let Some(rest) = trimmed.strip_prefix("cd").and_then(|r| { - let r = r.trim(); - if r.is_empty() { None } else { Some(r) } - }) { - // Resolve the path via a quick subprocess, then store it. - let target = rest.to_string(); - let cwd = self.cwd.clone(); - let check = spawn_cmd_windows(&format!("cd /D \"{target}\" && cd"), cwd, timeout).await; - match check { - Ok((stdout, _, code)) if code == 0 => { - let new_dir = stdout.lines().last().unwrap_or("").trim().to_string(); - if !new_dir.is_empty() { - self.cwd = Some(new_dir.clone()); - return Ok((format!("Changed directory to {new_dir}\n"), String::new(), 0)); - } - return Ok((String::new(), String::new(), 0)); - } - Ok((_, stderr, code)) => { - return Ok((String::new(), stderr, code)); - } - Err(e) => return Err(e), - } + { + // /D = skip AutoRun registry (no user `clear` hooks) + // /C = run command and exit + let mut cmd = Command::new("cmd.exe"); + cmd.args(["/D", "/C", command]); + run_captured(cmd, Duration::from_millis(TIMEOUT_MS)).await + } + #[cfg(not(windows))] + { + let mut cmd = Command::new("sh"); + cmd.args(["-c", command]); + run_captured(cmd, Duration::from_millis(TIMEOUT_MS)).await } - - spawn_cmd_windows(command, self.cwd.clone(), timeout).await } } -#[cfg(windows)] -async fn spawn_cmd_windows( - command: &str, - cwd: Option, - timeout: Duration, -) -> Result<(String, String, i32), String> { - // Use cmd.exe with UTF-8 codepage so powershell / unicode output works. - let full_cmd = format!("chcp 65001 >nul 2>&1 && {command}"); - - let mut cmd = Command::new("cmd.exe"); - // /D disables AutoRun registry commands (prevents user's `clear` or other - // startup hooks from wiping the client's terminal output). - cmd.args(["/D", "/C", &full_cmd]); - - if let Some(ref dir) = cwd { - cmd.current_dir(dir); - } - - run_with_timeout(cmd, timeout).await -} - -#[cfg(not(windows))] -async fn run_unix(command: &str, timeout: Duration) -> Result<(String, String, i32), String> { - let mut cmd = Command::new("sh"); - cmd.args(["-c", command]); - run_with_timeout(cmd, timeout).await -} - -async fn run_with_timeout( +async fn run_captured( mut cmd: Command, timeout: Duration, ) -> Result<(String, String, i32), String> { - // Spawn and wait with timeout. - let child = cmd - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) - .spawn() + cmd.stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()); + + let child = cmd.spawn() .map_err(|e| format!("Failed to spawn process: {e}"))?; match tokio::time::timeout(timeout, child.wait_with_output()).await { - Ok(Ok(output)) => { - // from_utf8_lossy never fails — replaces invalid bytes with U+FFFD. - let stdout = String::from_utf8_lossy(&output.stdout).into_owned(); - let stderr = String::from_utf8_lossy(&output.stderr).into_owned(); - let exit_code = output.status.code().unwrap_or(-1); - Ok((stdout, stderr, exit_code)) - } + Ok(Ok(out)) => Ok(( + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + out.status.code().unwrap_or(-1), + )), Ok(Err(e)) => Err(format!("Process wait failed: {e}")), - Err(_) => Err(format!("Command timed out after {}ms", timeout.as_millis())), + Err(_) => Err(format!("Command timed out after {}ms", timeout.as_millis())), } }