use super::{
Tool, ToolExecError,
context::ToolContext,
shell_util::{format_shell_output, resolve_workdir, run_shell_streaming, spawn_with_watchdog},
};
use base64::Engine as _;
use choreo_keystore::ServiceCredential;
use crossbeam_channel;
use schemars::JsonSchema;
use serde::Deserialize;
use std::path::Path;
#[derive(Debug, Clone, Copy, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PsShell {
Powershell,
Pwsh,
}
impl JsonSchema for PsShell {
fn schema_name() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed("PsShell")
}
fn schema_id() -> std::borrow::Cow<'static, str> {
std::borrow::Cow::Borrowed(concat!(module_path!(), "::PsShell"))
}
fn json_schema(_gen: &mut schemars::SchemaGenerator) -> schemars::Schema {
schemars::json_schema!({
"type": "string",
"enum": ["powershell", "pwsh"]
})
}
}
impl PsShell {
fn binary(self) -> &'static str {
match self {
PsShell::Powershell => "powershell",
PsShell::Pwsh => "pwsh",
}
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct PsArgs {
pub command: String,
pub shell: PsShell,
pub workdir: Option<String>,
pub timeout: Option<u64>,
}
pub(crate) struct PowerShell;
impl Tool for PowerShell {
type Args = PsArgs;
type Return = String;
type Error = ToolExecError;
fn name(&self) -> &'static str {
"powershell"
}
fn group(&self) -> &'static str {
"shell"
}
fn description(&self) -> &'static str {
"Execute a PowerShell command using a POSIX-adjacent shell (Windows PowerShell 5.1 or PowerShell 7+). Runs via `-EncodedCommand` (Base64 UTF-16LE), so quotes and special characters need no escaping; output is forced to UTF-8. Non-interactive only — commands that read from stdin will hang. Use `exit N` to set a nonzero exit code. Prefer `exec` when you need to run a single program without shell features."
}
fn supports_streaming_output() -> bool {
true
}
fn describe_invocation(&self, args: &Self::Args) -> String {
let mut parts = vec![format!("Running PowerShell command: `{}`.", args.command)];
parts.push(format!(" Shell: {:?}.", args.shell));
if let Some(timeout) = args.timeout {
parts.push(format!(" Timeout: {}ms.", timeout));
}
parts.concat()
}
fn execute(
&self,
args: Self::Args,
_x_credentials: Option<&ServiceCredential>,
working_dir: Option<&Path>,
_ctx: Option<&ToolContext>,
) -> Result<Self::Return, Self::Error> {
execute_ps_tool(&args, working_dir)
}
fn execute_streaming(
&self,
args: Self::Args,
_x_credentials: Option<&ServiceCredential>,
working_dir: Option<&Path>,
output_tx: crossbeam_channel::Sender<Vec<u8>>,
_ctx: Option<&ToolContext>,
) -> Result<Self::Return, Self::Error> {
let command = &args.command;
let timeout_ms = args.timeout.unwrap_or(30000);
let resolved = resolve_workdir(args.workdir.as_deref(), working_dir);
let mut cmd = build_ps_command(args.shell, command, &resolved);
run_shell_streaming(&mut cmd, command, timeout_ms, output_tx)
}
fn return_string(ret: &Self::Return) -> String {
ret.clone()
}
}
fn build_ps_script(command: &str) -> String {
format!(
"[Console]::OutputEncoding=[System.Text.Encoding]::UTF8; \
$ProgressPreference='SilentlyContinue'; \
{command}"
)
}
fn encode_ps_script(script: &str) -> String {
let utf16le: Vec<u8> = script
.encode_utf16()
.flat_map(|unit| unit.to_le_bytes())
.collect();
base64::engine::general_purpose::STANDARD.encode(utf16le)
}
pub(crate) fn build_ps_command(
shell: PsShell,
command: &str,
workdir: &Path,
) -> std::process::Command {
let encoded = encode_ps_script(&build_ps_script(command));
let mut cmd = std::process::Command::new(shell.binary());
cmd.args(["-NoProfile", "-NonInteractive", "-EncodedCommand", &encoded])
.current_dir(workdir)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped());
cmd
}
pub fn execute_ps_tool(args: &PsArgs, working_dir: Option<&Path>) -> Result<String, ToolExecError> {
let command = &args.command;
let timeout_ms = args.timeout.unwrap_or(30000);
let resolved = resolve_workdir(args.workdir.as_deref(), working_dir);
tracing::debug!(
shell = ?args.shell,
timeout_ms,
workdir = %resolved.display(),
"executing powershell tool"
);
let mut cmd = build_ps_command(args.shell, command, &resolved);
let (output, was_killed) = spawn_with_watchdog(&mut cmd, timeout_ms)?;
Ok(format_shell_output(
command, &output, timeout_ms, was_killed,
))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tools::Tool;
use schemars::SchemaGenerator;
#[test]
fn powershell_tool_has_valid_metadata() {
let tool = super::PowerShell;
assert!(!tool.name().is_empty());
assert!(!tool.description().is_empty());
let schema = tool.schema();
assert!(schema.is_object());
}
#[test]
fn ps_shell_enum_json_schema_uses_flat_enum_format() {
let mut generator = SchemaGenerator::default();
let schema = super::PsShell::json_schema(&mut generator);
let json: serde_json::Value = serde_json::to_value(&schema).unwrap();
assert_eq!(json["type"], "string", "PsShell should be a string schema");
let variants: Vec<&str> = json["enum"]
.as_array()
.expect("PsShell should have an enum array")
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(variants, vec!["powershell", "pwsh"]);
}
#[test]
fn powershell_tool_schema_shell_param_uses_flat_enum() {
let schema = super::PowerShell.schema();
let shell_schema = &schema["properties"]["shell"];
assert_eq!(shell_schema["type"], "string");
let variants: Vec<&str> = shell_schema["enum"]
.as_array()
.expect("shell property should have an enum array")
.iter()
.filter_map(|v| v.as_str())
.collect();
assert_eq!(variants, vec!["powershell", "pwsh"]);
}
#[test]
fn build_ps_script_prepends_utf8_preamble() {
let script = build_ps_script("Write-Output 'héllo'");
assert!(script.starts_with("[Console]::OutputEncoding="));
assert!(script.contains("$ProgressPreference='SilentlyContinue'"));
assert!(script.ends_with("Write-Output 'héllo'"));
}
#[test]
fn encode_ps_script_round_trips_utf16le() {
let script = build_ps_script("echo \"a'b\" %PATH% ^! ~");
let encoded = encode_ps_script(&script);
let bytes = base64::engine::general_purpose::STANDARD
.decode(&encoded)
.expect("valid base64");
assert_eq!(bytes.len() % 2, 0, "UTF-16LE is whole 2-byte units");
let units: Vec<u16> = bytes
.as_chunks::<2>()
.0
.iter()
.map(|c| u16::from_le_bytes(*c))
.collect();
let decoded = String::from_utf16(&units).expect("valid UTF-16");
assert_eq!(decoded, script);
}
#[test]
fn build_ps_command_uses_encodedcommand_and_pipes() {
let cmd = build_ps_command(
PsShell::Powershell,
"Get-ChildItem",
std::path::Path::new("."),
);
assert_eq!(cmd.get_program(), "powershell");
let args: Vec<String> = cmd
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
assert_eq!(
&args[..3],
["-NoProfile", "-NonInteractive", "-EncodedCommand"]
);
let encoded = &args[3];
let decoded = base64::engine::general_purpose::STANDARD
.decode(encoded)
.expect("valid base64");
let units: Vec<u16> = decoded
.as_chunks::<2>()
.0
.iter()
.map(|c| u16::from_le_bytes(*c))
.collect();
let script = String::from_utf16(&units).expect("valid UTF-16");
assert!(script.ends_with("Get-ChildItem"));
}
#[test]
fn ps_shell_binaries() {
assert_eq!(PsShell::Powershell.binary(), "powershell");
assert_eq!(PsShell::Pwsh.binary(), "pwsh");
}
}