Skip to main content

vtcode_core/pods/
transport.rs

1use anyhow::{Context, Result, anyhow};
2use async_trait::async_trait;
3use std::process::Stdio;
4use tokio::io::AsyncWriteExt;
5use tokio::process::Command;
6
7/// Captured command output from a pod transport.
8#[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/// SSH-backed transport used by the real CLI.
23#[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 {}