From 537ed95a3cce97cb20ae227e8c14ba2e716f3cea Mon Sep 17 00:00:00 2001 From: Helios Agent Date: Tue, 3 Mar 2026 16:05:29 +0100 Subject: [PATCH] feat: configurable exec timeout per request (--timeout flag, default 30s) --- crates/client/src/main.rs | 5 ++--- crates/client/src/shell.rs | 13 ++++++------- crates/common/src/protocol.rs | 2 ++ crates/server/src/api.rs | 28 +++++++++++++++++++++++++--- skills/remote.py | 13 +++++++++---- 5 files changed, 44 insertions(+), 17 deletions(-) diff --git a/crates/client/src/main.rs b/crates/client/src/main.rs index ffc1672..8a3645f 100644 --- a/crates/client/src/main.rs +++ b/crates/client/src/main.rs @@ -392,8 +392,7 @@ async fn handle_message( ClientMessage::Ack { request_id } } - ServerMessage::ExecRequest { request_id, command } => { - // Truncate long commands for display + ServerMessage::ExecRequest { request_id, command, timeout_ms } => { let cmd_display = if command.len() > 60 { format!("{}…", &command[..60]) } else { @@ -401,7 +400,7 @@ async fn handle_message( }; log_cmd!("exec › {}", cmd_display); let mut sh = shell.lock().await; - match sh.run(&command).await { + match sh.run(&command, timeout_ms).await { Ok((stdout, stderr, exit_code)) => { let out = stdout.trim().lines().next().unwrap_or("").to_string(); let out_display = if out.len() > 60 { diff --git a/crates/client/src/shell.rs b/crates/client/src/shell.rs index 8eab881..ad4b4bd 100644 --- a/crates/client/src/shell.rs +++ b/crates/client/src/shell.rs @@ -4,28 +4,27 @@ use std::time::Duration; use tokio::process::Command; -const TIMEOUT_MS: u64 = 30_000; +const DEFAULT_TIMEOUT_MS: u64 = 30_000; pub struct PersistentShell; impl PersistentShell { pub fn new() -> Self { Self } - pub async fn run(&mut self, command: &str) -> Result<(String, String, i32), String> { + pub async fn run(&mut self, command: &str, timeout_ms: Option) -> Result<(String, String, i32), String> { + let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS); + let timeout = Duration::from_millis(timeout_ms); #[cfg(windows)] { - // -NoProfile: skip $PROFILE (prevents user's `clear` from running) - // -NonInteractive: no prompts - // -Command: run the given command string let mut cmd = Command::new("powershell.exe"); cmd.args(["-NoProfile", "-NonInteractive", "-Command", command]); - run_captured(cmd, Duration::from_millis(TIMEOUT_MS)).await + run_captured(cmd, timeout).await } #[cfg(not(windows))] { let mut cmd = Command::new("sh"); cmd.args(["-c", command]); - run_captured(cmd, Duration::from_millis(TIMEOUT_MS)).await + run_captured(cmd, timeout).await } } } diff --git a/crates/common/src/protocol.rs b/crates/common/src/protocol.rs index a239176..710396c 100644 --- a/crates/common/src/protocol.rs +++ b/crates/common/src/protocol.rs @@ -27,6 +27,8 @@ pub enum ServerMessage { ExecRequest { request_id: Uuid, command: String, + /// Timeout in milliseconds. None = use client default (30s) + timeout_ms: Option, }, /// Simulate a mouse click ClickRequest { diff --git a/crates/server/src/api.rs b/crates/server/src/api.rs index 4d75031..e4a4e95 100644 --- a/crates/server/src/api.rs +++ b/crates/server/src/api.rs @@ -60,6 +60,19 @@ async fn dispatch( op: &str, make_msg: F, ) -> Result)> +where + F: FnOnce(Uuid) -> ServerMessage, +{ + dispatch_with_timeout(state, session_id, op, make_msg, REQUEST_TIMEOUT).await +} + +async fn dispatch_with_timeout( + state: &AppState, + session_id: &str, + op: &str, + make_msg: F, + timeout: Duration, +) -> Result)> where F: FnOnce(Uuid) -> ServerMessage, { @@ -86,7 +99,7 @@ where send_error(session_id, op) })?; - match tokio::time::timeout(REQUEST_TIMEOUT, rx).await { + match tokio::time::timeout(timeout, rx).await { Ok(Ok(response)) => Ok(response), Ok(Err(_)) => Err(send_error(session_id, op)), Err(_) => Err(timeout_error(session_id, op)), @@ -143,6 +156,9 @@ pub async fn request_screenshot( #[derive(Deserialize)] pub struct ExecBody { pub command: String, + /// Optional timeout in milliseconds (default: 30000). Use higher values for + /// long-running commands like downloads. + pub timeout_ms: Option, } pub async fn request_exec( @@ -150,10 +166,16 @@ pub async fn request_exec( State(state): State, Json(body): Json, ) -> impl IntoResponse { - match dispatch(&state, &session_id, "exec", |rid| ServerMessage::ExecRequest { + // Server-side wait must be at least as long as the client timeout + buffer + let server_timeout = body.timeout_ms + .map(|ms| std::time::Duration::from_millis(ms + 5_000)) + .unwrap_or(REQUEST_TIMEOUT); + + match dispatch_with_timeout(&state, &session_id, "exec", |rid| ServerMessage::ExecRequest { request_id: rid, command: body.command.clone(), - }) + timeout_ms: body.timeout_ms, + }, server_timeout) .await { Ok(ClientMessage::ExecResponse { diff --git a/skills/remote.py b/skills/remote.py index 65373fa..be783a1 100644 --- a/skills/remote.py +++ b/skills/remote.py @@ -38,14 +38,14 @@ def _headers() -> dict: return {"X-Api-Key": API_KEY, "Content-Type": "application/json"} -def _req(method: str, path: str, **kwargs): +def _req(method: str, path: str, timeout: int = 30, **kwargs): url = f"{BASE_URL}{path}" try: - resp = requests.request(method, url, headers=_headers(), timeout=30, **kwargs) + resp = requests.request(method, url, headers=_headers(), timeout=timeout, **kwargs) except requests.exceptions.ConnectionError as exc: sys.exit(f"[helios-remote] CONNECTION ERROR: Cannot reach {url}\n → {exc}") except requests.exceptions.Timeout: - sys.exit(f"[helios-remote] TIMEOUT: {url} did not respond within 30 s") + sys.exit(f"[helios-remote] TIMEOUT: {url} did not respond within {timeout} s") if not resp.ok: body = resp.text[:1000] if resp.text else "(empty body)" @@ -141,7 +141,10 @@ def cmd_exec(args): """Run a shell command on the remote session.""" sid = resolve_session(args.session_id) command = " ".join(args.parts) if isinstance(args.parts, list) else args.parts - resp = _req("POST", f"/sessions/{sid}/exec", json={"command": command}) + body = {"command": command} + if args.timeout: + body["timeout_ms"] = args.timeout * 1000 # seconds → ms + resp = _req("POST", f"/sessions/{sid}/exec", json=body, timeout=max(35, (args.timeout or 30) + 5)) data = resp.json() stdout = data.get("stdout") or data.get("output") or "" @@ -343,6 +346,8 @@ def build_parser() -> argparse.ArgumentParser: ep.add_argument("session_id") ep.add_argument("parts", nargs=argparse.REMAINDER, metavar="command", help="Command (and arguments) to execute") + ep.add_argument("--timeout", type=int, default=None, metavar="SECONDS", + help="Timeout in seconds (default: 30). Use higher for long downloads etc.") cp = sub.add_parser("click", help="Send a mouse click") cp.add_argument("session_id")