Skip to main content

rpi_tools/tools/
powershell.rs

1//! Optional Windows PowerShell tool, matching upstream's `powershell` tool.
2//! The command is passed through `-EncodedCommand` so quotes and newlines in
3//! user input cannot be reinterpreted by the outer Bash transport.
4
5use std::sync::Arc;
6
7use async_trait::async_trait;
8use base64::{engine::general_purpose::STANDARD, Engine as _};
9use rpi_agent::agent_tool::AgentTool;
10use rpi_agent::error::AgentError;
11use rpi_agent::types::{AgentToolResult, TextContentOrImage, ToolResultPartial};
12use rpi_ai::types::Tool;
13use schemars::JsonSchema;
14use serde::Deserialize;
15use tokio_util::sync::CancellationToken;
16
17use crate::shell_output::{execute_shell_with_capture, ShellCaptureOptions};
18use crate::tools::tool_context::ExecutionToolContext;
19
20#[derive(Debug, Clone, JsonSchema, Deserialize)]
21pub struct PowerShellInput {
22    pub command: String,
23    #[serde(default)]
24    pub timeout: Option<f64>,
25}
26
27pub struct PowerShellTool {
28    schema: Tool,
29    env: Arc<dyn crate::env::ExecutionEnv>,
30}
31
32impl PowerShellTool {
33    fn schema() -> Tool {
34        Tool {
35            name: "powershell".into(),
36            description: "Execute a PowerShell command on Windows. Returns stdout and stderr."
37                .into(),
38            parameters: rpi_ai::types::Schema::new(
39                serde_json::to_value(schemars::schema_for!(PowerShellInput)).unwrap_or_default(),
40            ),
41            constrained_sampling: None,
42        }
43    }
44}
45
46pub fn create_powershell_tool(context: &ExecutionToolContext) -> Arc<dyn AgentTool> {
47    Arc::new(PowerShellTool {
48        schema: PowerShellTool::schema(),
49        env: context.env().clone(),
50    })
51}
52
53#[async_trait]
54impl AgentTool for PowerShellTool {
55    fn schema(&self) -> &Tool {
56        &self.schema
57    }
58    fn label(&self) -> &str {
59        "powershell"
60    }
61
62    async fn execute(
63        &self,
64        _tool_call_id: &str,
65        params: serde_json::Value,
66        signal: CancellationToken,
67        _on_update: Arc<dyn Fn(ToolResultPartial) + Send + Sync>,
68    ) -> Result<AgentToolResult, AgentError> {
69        if !cfg!(windows) {
70            return Err(AgentError::Tool(
71                "PowerShell tool is only available on Windows".into(),
72            ));
73        }
74        let input: PowerShellInput = serde_json::from_value(params)
75            .map_err(|e| AgentError::Validation(format!("powershell input invalid: {e}")))?;
76        let utf16: Vec<u8> = input
77            .command
78            .encode_utf16()
79            .flat_map(u16::to_le_bytes)
80            .collect();
81        let encoded = STANDARD.encode(utf16);
82        let command =
83            format!("powershell.exe -NoLogo -NoProfile -NonInteractive -EncodedCommand {encoded}");
84        let capture = execute_shell_with_capture(
85            &self.env,
86            &command,
87            ShellCaptureOptions {
88                cwd: Some(self.env.cwd().to_path_buf()),
89                env: None,
90                inherit_env: true,
91                timeout: input.timeout,
92                cancel: Some(&signal),
93                on_chunk: None,
94                return_execution_errors: true,
95            },
96        )
97        .await
98        .map_err(|e| AgentError::Tool(e.to_string()))?;
99        let mut output = capture.output;
100        if capture.cancelled {
101            return Err(AgentError::Tool("PowerShell command aborted".into()));
102        }
103        if let Some(error) = capture.execution_error {
104            return Err(AgentError::Tool(error.to_string()));
105        }
106        if let Some(code) = capture.exit_code.filter(|code| *code != 0) {
107            output.push_str(&format!("\n\nPowerShell exited with code {code}"));
108            return Err(AgentError::Tool(output));
109        }
110        if output.trim().is_empty() {
111            output = "(no output)".into();
112        }
113        Ok(AgentToolResult {
114            content: vec![TextContentOrImage::text(output)],
115            ..Default::default()
116        })
117    }
118}