vtcode_core/pods/
transport.rs1use anyhow::{Context, Result, anyhow};
2use async_trait::async_trait;
3use std::process::Stdio;
4use tokio::io::AsyncWriteExt;
5use tokio::process::Command;
6
7#[derive(Debug, Clone, Default)]
9pub struct CommandOutput {
10 pub success: bool,
11 pub stdout: String,
12 pub stderr: String,
13}
14
15#[async_trait]
16pub trait PodTransport: Send + Sync {
17 async fn exec_capture(&self, ssh_target: &str, command: &str) -> Result<CommandOutput>;
18 async fn write_file(&self, ssh_target: &str, remote_path: &str, contents: &str) -> Result<()>;
19 async fn exec_stream(&self, ssh_target: &str, command: &str) -> Result<()>;
20}
21
22#[derive(Debug, Clone, Default)]
24pub struct SshTransport;
25
26#[async_trait]
27impl PodTransport for SshTransport {
28 async fn exec_capture(&self, ssh_target: &str, command: &str) -> Result<CommandOutput> {
29 let mut ssh = build_ssh_command(ssh_target, command)?;
30 ssh.stdout(Stdio::piped()).stderr(Stdio::piped());
31
32 let output = ssh
33 .output()
34 .await
35 .with_context(|| format!("failed to execute SSH command: {command}"))?;
36
37 Ok(CommandOutput {
38 success: output.status.success(),
39 stdout: String::from_utf8_lossy(&output.stdout).to_string(),
40 stderr: String::from_utf8_lossy(&output.stderr).to_string(),
41 })
42 }
43
44 async fn write_file(&self, ssh_target: &str, remote_path: &str, contents: &str) -> Result<()> {
45 let remote_command = format!("cat > {remote_path}");
46 let mut ssh = build_ssh_command(ssh_target, &remote_command)?;
47 ssh.stdin(Stdio::piped())
48 .stdout(Stdio::null())
49 .stderr(Stdio::piped());
50
51 let mut child = ssh
52 .spawn()
53 .with_context(|| format!("failed to spawn SSH writer for {remote_path}"))?;
54
55 if let Some(mut stdin) = child.stdin.take() {
56 stdin
57 .write_all(contents.as_bytes())
58 .await
59 .with_context(|| format!("failed to write remote file {remote_path}"))?;
60 } else {
61 return Err(anyhow!("SSH writer did not provide stdin"));
62 }
63
64 let output = child
65 .wait_with_output()
66 .await
67 .with_context(|| format!("failed to finish SSH writer for {remote_path}"))?;
68
69 if output.status.success() {
70 Ok(())
71 } else {
72 Err(anyhow!(
73 "remote file write failed for {remote_path}: {}",
74 String::from_utf8_lossy(&output.stderr)
75 ))
76 }
77 }
78
79 async fn exec_stream(&self, ssh_target: &str, command: &str) -> Result<()> {
80 let mut ssh = build_ssh_command(ssh_target, command)?;
81 ssh.stdin(Stdio::null())
82 .stdout(Stdio::inherit())
83 .stderr(Stdio::inherit());
84
85 let status = ssh
86 .status()
87 .await
88 .with_context(|| format!("failed to stream SSH command: {command}"))?;
89
90 if status.success() {
91 Ok(())
92 } else {
93 Err(anyhow!("SSH stream command failed with status {status}"))
94 }
95 }
96}
97
98fn build_ssh_command(ssh_target: &str, remote_command: &str) -> Result<Command> {
99 let parts = shell_words::split(ssh_target)
100 .with_context(|| format!("failed to parse SSH target: {ssh_target}"))?;
101
102 let Some((program, args)) = parts.split_first() else {
103 return Err(anyhow!("SSH target is empty"));
104 };
105
106 let mut command = Command::new(program);
107 command.args(args);
108 command.arg(remote_command);
109 Ok(command)
110}
111
112#[cfg(test)]
113mod tests {}