feat: configurable exec timeout per request (--timeout flag, default 30s)

This commit is contained in:
Helios Agent 2026-03-03 16:05:29 +01:00
parent 1c0af1693b
commit 537ed95a3c
No known key found for this signature in database
GPG key ID: C8259547CD8309B5
5 changed files with 44 additions and 17 deletions

View file

@ -392,8 +392,7 @@ async fn handle_message(
ClientMessage::Ack { request_id } ClientMessage::Ack { request_id }
} }
ServerMessage::ExecRequest { request_id, command } => { ServerMessage::ExecRequest { request_id, command, timeout_ms } => {
// Truncate long commands for display
let cmd_display = if command.len() > 60 { let cmd_display = if command.len() > 60 {
format!("{}", &command[..60]) format!("{}", &command[..60])
} else { } else {
@ -401,7 +400,7 @@ async fn handle_message(
}; };
log_cmd!("exec {}", cmd_display); log_cmd!("exec {}", cmd_display);
let mut sh = shell.lock().await; let mut sh = shell.lock().await;
match sh.run(&command).await { match sh.run(&command, timeout_ms).await {
Ok((stdout, stderr, exit_code)) => { Ok((stdout, stderr, exit_code)) => {
let out = stdout.trim().lines().next().unwrap_or("").to_string(); let out = stdout.trim().lines().next().unwrap_or("").to_string();
let out_display = if out.len() > 60 { let out_display = if out.len() > 60 {

View file

@ -4,28 +4,27 @@
use std::time::Duration; use std::time::Duration;
use tokio::process::Command; use tokio::process::Command;
const TIMEOUT_MS: u64 = 30_000; const DEFAULT_TIMEOUT_MS: u64 = 30_000;
pub struct PersistentShell; pub struct PersistentShell;
impl PersistentShell { impl PersistentShell {
pub fn new() -> Self { Self } 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<u64>) -> Result<(String, String, i32), String> {
let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TIMEOUT_MS);
let timeout = Duration::from_millis(timeout_ms);
#[cfg(windows)] #[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"); let mut cmd = Command::new("powershell.exe");
cmd.args(["-NoProfile", "-NonInteractive", "-Command", command]); cmd.args(["-NoProfile", "-NonInteractive", "-Command", command]);
run_captured(cmd, Duration::from_millis(TIMEOUT_MS)).await run_captured(cmd, timeout).await
} }
#[cfg(not(windows))] #[cfg(not(windows))]
{ {
let mut cmd = Command::new("sh"); let mut cmd = Command::new("sh");
cmd.args(["-c", command]); cmd.args(["-c", command]);
run_captured(cmd, Duration::from_millis(TIMEOUT_MS)).await run_captured(cmd, timeout).await
} }
} }
} }

View file

@ -27,6 +27,8 @@ pub enum ServerMessage {
ExecRequest { ExecRequest {
request_id: Uuid, request_id: Uuid,
command: String, command: String,
/// Timeout in milliseconds. None = use client default (30s)
timeout_ms: Option<u64>,
}, },
/// Simulate a mouse click /// Simulate a mouse click
ClickRequest { ClickRequest {

View file

@ -60,6 +60,19 @@ async fn dispatch<F>(
op: &str, op: &str,
make_msg: F, make_msg: F,
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)> ) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
where
F: FnOnce(Uuid) -> ServerMessage,
{
dispatch_with_timeout(state, session_id, op, make_msg, REQUEST_TIMEOUT).await
}
async fn dispatch_with_timeout<F>(
state: &AppState,
session_id: &str,
op: &str,
make_msg: F,
timeout: Duration,
) -> Result<ClientMessage, (StatusCode, Json<ErrorBody>)>
where where
F: FnOnce(Uuid) -> ServerMessage, F: FnOnce(Uuid) -> ServerMessage,
{ {
@ -86,7 +99,7 @@ where
send_error(session_id, op) 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(Ok(response)) => Ok(response),
Ok(Err(_)) => Err(send_error(session_id, op)), Ok(Err(_)) => Err(send_error(session_id, op)),
Err(_) => Err(timeout_error(session_id, op)), Err(_) => Err(timeout_error(session_id, op)),
@ -143,6 +156,9 @@ pub async fn request_screenshot(
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct ExecBody { pub struct ExecBody {
pub command: String, pub command: String,
/// Optional timeout in milliseconds (default: 30000). Use higher values for
/// long-running commands like downloads.
pub timeout_ms: Option<u64>,
} }
pub async fn request_exec( pub async fn request_exec(
@ -150,10 +166,16 @@ pub async fn request_exec(
State(state): State<AppState>, State(state): State<AppState>,
Json(body): Json<ExecBody>, Json(body): Json<ExecBody>,
) -> impl IntoResponse { ) -> 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, request_id: rid,
command: body.command.clone(), command: body.command.clone(),
}) timeout_ms: body.timeout_ms,
}, server_timeout)
.await .await
{ {
Ok(ClientMessage::ExecResponse { Ok(ClientMessage::ExecResponse {

View file

@ -38,14 +38,14 @@ def _headers() -> dict:
return {"X-Api-Key": API_KEY, "Content-Type": "application/json"} 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}" url = f"{BASE_URL}{path}"
try: 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: except requests.exceptions.ConnectionError as exc:
sys.exit(f"[helios-remote] CONNECTION ERROR: Cannot reach {url}\n{exc}") sys.exit(f"[helios-remote] CONNECTION ERROR: Cannot reach {url}\n{exc}")
except requests.exceptions.Timeout: 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: if not resp.ok:
body = resp.text[:1000] if resp.text else "(empty body)" 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.""" """Run a shell command on the remote session."""
sid = resolve_session(args.session_id) sid = resolve_session(args.session_id)
command = " ".join(args.parts) if isinstance(args.parts, list) else args.parts 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() data = resp.json()
stdout = data.get("stdout") or data.get("output") or "" 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("session_id")
ep.add_argument("parts", nargs=argparse.REMAINDER, metavar="command", ep.add_argument("parts", nargs=argparse.REMAINDER, metavar="command",
help="Command (and arguments) to execute") 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 = sub.add_parser("click", help="Send a mouse click")
cp.add_argument("session_id") cp.add_argument("session_id")