use crate::registry::{Tool, ToolContext};
use aegis_security::sanitize_credentials;
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};
use std::process::Stdio;
use std::time::Duration;
pub struct RemoteTool;
impl RemoteTool {
fn common_ssh_opts(port: u64) -> Vec<String> {
vec![
"-o".into(),
"StrictHostKeyChecking=accept-new".into(),
"-o".into(),
"ConnectTimeout=10".into(),
"-o".into(),
"BatchMode=no".into(),
"-p".into(),
port.to_string(),
]
}
fn common_scp_opts(port: u64) -> Vec<String> {
vec![
"-o".into(),
"StrictHostKeyChecking=accept-new".into(),
"-o".into(),
"ConnectTimeout=10".into(),
"-P".into(), port.to_string(),
]
}
}
#[async_trait]
impl Tool for RemoteTool {
fn name(&self) -> &str {
"remote"
}
fn description(&self) -> &str {
"Operate a remote server over SSH. Prefer a saved `server` handle (e.g. \
server=\"srv1\") so host/user/password are resolved locally and never sent \
to the model; otherwise pass host + user + (password OR SSH key). Actions: \
run (command), upload (local→remote file), check (connectivity + OS). \
Password auth needs `sshpass` locally. High-risk: each call is gated by \
approval. Saved servers are added by the user via the `/server` command."
}
fn parameters(&self) -> Value {
json!({
"type": "object",
"properties": {
"action": { "type": "string", "enum": ["run", "upload", "check"] },
"server": { "type": "string", "description": "Saved server handle (resolved locally; preferred over host/user/password)." },
"host": { "type": "string", "description": "Server hostname or IP (if no `server`)" },
"user": { "type": "string", "description": "SSH username (if no `server`)" },
"password": { "type": "string", "description": "SSH password (optional; needs sshpass)" },
"port": { "type": "integer", "description": "SSH port (default 22)" },
"command": { "type": "string", "description": "Shell command to run (action=run)" },
"local": { "type": "string", "description": "Local file path (action=upload)" },
"remote": { "type": "string", "description": "Remote destination path (action=upload)" }
},
"required": ["action"]
})
}
async fn execute(&self, args: Value, ctx: &ToolContext<'_>) -> Result<String> {
let action = args["action"].as_str().unwrap_or("run");
let (host, user, port, password_owned): (String, String, u64, Option<String>) =
if let Some(name) = args["server"].as_str().filter(|s| !s.is_empty()) {
match crate::remotes::get(name) {
Some(c) => (c.host, c.user, c.port, c.password),
None => {
return Ok(format!(
"No saved server '{name}'. Add it locally with the `/server add` command first."
))
}
}
} else {
(
args["host"].as_str().unwrap_or("").trim().to_string(),
args["user"].as_str().unwrap_or("").trim().to_string(),
args["port"].as_u64().unwrap_or(22),
args["password"].as_str().filter(|p| !p.is_empty()).map(|s| s.to_string()),
)
};
let host = host.as_str();
let user = user.as_str();
if host.is_empty() || user.is_empty() {
return Ok("Error: provide `server` (saved handle) or `host`+`user`.".to_string());
}
let password = password_owned.as_deref();
let target = format!("{user}@{host}");
let what = match action {
"upload" => format!(
"upload {} → {host}:{}",
args["local"].as_str().unwrap_or("?"),
args["remote"].as_str().unwrap_or("?")
),
"check" => format!("check connectivity to {target}"),
_ => format!("run on {target}: {}", args["command"].as_str().unwrap_or("")),
};
if !ctx.approve(&format!("⚠️ REMOTE SSH ({host}): {what}")) {
return Ok("Remote action blocked: user denied.".to_string());
}
let mut cmd = match action {
"upload" => {
let local = args["local"].as_str().unwrap_or("");
let remote = args["remote"].as_str().unwrap_or("");
if local.is_empty() || remote.is_empty() {
return Ok("Error: 'local' and 'remote' are required for upload.".to_string());
}
let mut c = if password.is_some() {
let mut c = tokio::process::Command::new("sshpass");
c.arg("-e").arg("scp");
c
} else {
tokio::process::Command::new("scp")
};
c.args(Self::common_scp_opts(port))
.arg(local)
.arg(format!("{target}:{remote}"));
c
}
_ => {
let remote_cmd = if action == "check" {
"echo aegis-remote-ok && uname -a && (command -v aegis >/dev/null && echo 'aegis: present' || echo 'aegis: not installed')"
} else {
args["command"].as_str().unwrap_or("")
};
if remote_cmd.is_empty() {
return Ok("Error: 'command' is required for action=run.".to_string());
}
let mut c = if password.is_some() {
let mut c = tokio::process::Command::new("sshpass");
c.arg("-e").arg("ssh");
c
} else {
tokio::process::Command::new("ssh")
};
c.args(Self::common_ssh_opts(port))
.arg(&target)
.arg(remote_cmd);
c
}
};
if let Some(pw) = password {
cmd.env("SSHPASS", pw);
}
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
let output = tokio::time::timeout(Duration::from_secs(120), cmd.output())
.await
.map_err(|_| anyhow::anyhow!("remote action timed out after 120s"))?;
let output = match output {
Ok(o) => o,
Err(e) => {
if e.kind() == std::io::ErrorKind::NotFound {
return Ok(format!(
"Error: required program not found ({e}). Password auth needs `sshpass` installed locally (e.g. `apt install sshpass`); or use an SSH key and omit `password`."
));
}
return Ok(format!("Error launching remote command: {e}"));
}
};
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let code = output.status.code().unwrap_or(-1);
let mut result = String::new();
if !stdout.is_empty() {
result.push_str(&stdout);
}
if !stderr.is_empty() {
if !result.is_empty() {
result.push('\n');
}
result.push_str("[stderr]\n");
result.push_str(&stderr);
}
if code != 0 {
result.push_str(&format!("\n[exit code: {code}]"));
}
if result.len() > 50_000 {
result.truncate(result.floor_char_boundary(50_000));
result.push_str("\n... [output truncated at 50KB]");
}
Ok(sanitize_credentials(&result))
}
}