feat: goose windows (#880)
Co-authored-by: Ryan Versaw <ryan@versaw.com>
This commit is contained in:
@@ -46,6 +46,10 @@ tracing = "0.1"
|
||||
chrono = "0.4"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json", "time"] }
|
||||
tracing-appender = "0.2"
|
||||
winapi = { version = "0.3", features = ["wincred"], optional = true }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winapi = { version = "0.3", features = ["wincred"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
@@ -62,12 +62,29 @@ pub async fn handle_configure() -> Result<(), Box<dyn Error>> {
|
||||
);
|
||||
}
|
||||
Some(ConfigError::KeyringError(msg)) => {
|
||||
#[cfg(target_os = "macos")]
|
||||
println!(
|
||||
"\n {} Failed to access secure storage (keyring): {} \n Please check your system keychain and run '{}' again. \n If your system is unable to use the keyring, please try setting secret key(s) via environment variables.",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
println!(
|
||||
"\n {} Failed to access Windows Credential Manager: {} \n Please check Windows Credential Manager and run '{}' again. \n If your system is unable to use the Credential Manager, please try setting secret key(s) via environment variables.",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
|
||||
#[cfg(not(any(target_os = "macos", target_os = "windows")))]
|
||||
println!(
|
||||
"\n {} Failed to access secure storage: {} \n Please check your system's secure storage and run '{}' again. \n If your system is unable to use secure storage, please try setting secret key(s) via environment variables.",
|
||||
style("Error").red().italic(),
|
||||
msg,
|
||||
style("goose configure").cyan()
|
||||
);
|
||||
}
|
||||
Some(ConfigError::DeserializeError(msg)) => {
|
||||
println!(
|
||||
|
||||
@@ -12,7 +12,12 @@ use goose::tracing::langfuse_layer;
|
||||
/// Returns the directory where log files should be stored.
|
||||
/// Creates the directory structure if it doesn't exist.
|
||||
fn get_log_directory() -> Result<PathBuf> {
|
||||
let home = std::env::var("HOME").context("HOME environment variable not set")?;
|
||||
let home = if cfg!(windows) {
|
||||
std::env::var("USERPROFILE").context("USERPROFILE environment variable not set")?
|
||||
} else {
|
||||
std::env::var("HOME").context("HOME environment variable not set")?
|
||||
};
|
||||
|
||||
let base_log_dir = PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("goose")
|
||||
@@ -114,7 +119,11 @@ mod tests {
|
||||
|
||||
fn setup_temp_home() -> TempDir {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
env::set_var("HOME", temp_dir.path());
|
||||
if cfg!(windows) {
|
||||
env::set_var("USERPROFILE", temp_dir.path());
|
||||
} else {
|
||||
env::set_var("HOME", temp_dir.path());
|
||||
}
|
||||
temp_dir
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@ use indoc::{formatdoc, indoc};
|
||||
use reqwest::{Client, Url};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
collections::HashMap, fs, future::Future, os::unix::fs::PermissionsExt, path::PathBuf,
|
||||
pin::Pin, sync::Arc, sync::Mutex,
|
||||
collections::HashMap, fs, future::Future, path::PathBuf, pin::Pin, sync::Arc, sync::Mutex,
|
||||
};
|
||||
use tokio::process::Command;
|
||||
|
||||
@@ -18,6 +17,9 @@ use mcp_core::{
|
||||
use mcp_server::router::CapabilitiesBuilder;
|
||||
use mcp_server::Router;
|
||||
|
||||
mod platform;
|
||||
use platform::{create_system_automation, SystemAutomation};
|
||||
|
||||
/// An extension designed for non-developers to help them with common tasks like
|
||||
/// web scraping, data processing, and automation.
|
||||
#[derive(Clone)]
|
||||
@@ -27,6 +29,7 @@ pub struct ComputerControllerRouter {
|
||||
active_resources: Arc<Mutex<HashMap<String, Resource>>>,
|
||||
http_client: Client,
|
||||
instructions: String,
|
||||
system_automation: Arc<Box<dyn SystemAutomation + Send + Sync>>,
|
||||
}
|
||||
|
||||
impl Default for ComputerControllerRouter {
|
||||
@@ -86,9 +89,19 @@ impl ComputerControllerRouter {
|
||||
}),
|
||||
);
|
||||
|
||||
let computer_control_tool = Tool::new(
|
||||
"computer_control",
|
||||
indoc! {r#"
|
||||
let computer_control_desc = match std::env::consts::OS {
|
||||
"windows" => indoc! {r#"
|
||||
Control the computer using Windows system automation.
|
||||
|
||||
Features available:
|
||||
- PowerShell automation for system control
|
||||
- UI automation through PowerShell
|
||||
- File and system management
|
||||
- Windows-specific features and settings
|
||||
|
||||
Can be combined with screenshot tool for visual task assistance.
|
||||
"#},
|
||||
_ => indoc! {r#"
|
||||
Control the computer using AppleScript (macOS only). Automate applications and system features.
|
||||
|
||||
Key capabilities:
|
||||
@@ -104,14 +117,19 @@ impl ComputerControllerRouter {
|
||||
- Data: Interact with spreadsheets and documents
|
||||
|
||||
Can be combined with screenshot tool for visual task assistance.
|
||||
"#},
|
||||
"#},
|
||||
};
|
||||
|
||||
let computer_control_tool = Tool::new(
|
||||
"computer_control",
|
||||
computer_control_desc.to_string(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["script"],
|
||||
"properties": {
|
||||
"script": {
|
||||
"type": "string",
|
||||
"description": "The AppleScript content to execute"
|
||||
"description": "The automation script content (PowerShell for Windows, AppleScript for macOS)"
|
||||
},
|
||||
"save_output": {
|
||||
"type": "boolean",
|
||||
@@ -122,9 +140,18 @@ impl ComputerControllerRouter {
|
||||
}),
|
||||
);
|
||||
|
||||
let quick_script_tool = Tool::new(
|
||||
"automation_script",
|
||||
indoc! {r#"
|
||||
let quick_script_desc = match std::env::consts::OS {
|
||||
"windows" => indoc! {r#"
|
||||
Create and run small PowerShell or Batch scripts for automation tasks.
|
||||
PowerShell is recommended for most tasks.
|
||||
|
||||
The script is saved to a temporary file and executed.
|
||||
Some examples:
|
||||
- Sort unique lines: Get-Content file.txt | Sort-Object -Unique
|
||||
- Extract CSV column: Import-Csv file.csv | Select-Object -ExpandProperty Column2
|
||||
- Find text: Select-String -Pattern "pattern" -Path file.txt
|
||||
"#},
|
||||
_ => indoc! {r#"
|
||||
Create and run small scripts for automation tasks.
|
||||
Supports Shell and Ruby (on macOS).
|
||||
|
||||
@@ -135,14 +162,19 @@ impl ComputerControllerRouter {
|
||||
- create a sorted list of unique lines: sort file.txt | uniq
|
||||
- extract 2nd column in csv: awk -F "," '{ print $2}'
|
||||
- pattern matching: grep pattern file.txt
|
||||
"#},
|
||||
"#},
|
||||
};
|
||||
|
||||
let quick_script_tool = Tool::new(
|
||||
"automation_script",
|
||||
quick_script_desc.to_string(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["language", "script"],
|
||||
"properties": {
|
||||
"language": {
|
||||
"type": "string",
|
||||
"enum": ["shell", "ruby"],
|
||||
"enum": ["shell", "ruby", "powershell", "batch"],
|
||||
"description": "The scripting language to use"
|
||||
},
|
||||
"script": {
|
||||
@@ -186,9 +218,10 @@ impl ComputerControllerRouter {
|
||||
|
||||
// Create cache directory in user's home directory
|
||||
let cache_dir = dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("/tmp"))
|
||||
.unwrap_or_else(|| create_system_automation().get_temp_path())
|
||||
.join("goose")
|
||||
.join("computer_controller");
|
||||
|
||||
fs::create_dir_all(&cache_dir).unwrap_or_else(|_| {
|
||||
println!(
|
||||
"Warning: Failed to create cache directory at {:?}",
|
||||
@@ -196,8 +229,41 @@ impl ComputerControllerRouter {
|
||||
)
|
||||
});
|
||||
|
||||
let macos_browser_instructions = if std::env::consts::OS == "macos" {
|
||||
indoc! {r#"
|
||||
let system_automation: Arc<Box<dyn SystemAutomation + Send + Sync>> =
|
||||
Arc::new(create_system_automation());
|
||||
|
||||
let os_specific_instructions = match std::env::consts::OS {
|
||||
"windows" => indoc! {r#"
|
||||
Here are some extra tools:
|
||||
automation_script
|
||||
- Create and run PowerShell or Batch scripts
|
||||
- PowerShell is recommended for most tasks
|
||||
- Scripts can save their output to files
|
||||
- Windows-specific features:
|
||||
- PowerShell for system automation and UI control
|
||||
- Windows Management Instrumentation (WMI)
|
||||
- Registry access and system settings
|
||||
- Use the screenshot tool if needed to help with tasks
|
||||
|
||||
computer_control
|
||||
- System automation using PowerShell
|
||||
- Consider the screenshot tool to work out what is on screen and what to do to help with the control task.
|
||||
"#},
|
||||
_ => indoc! {r#"
|
||||
Here are some extra tools:
|
||||
automation_script
|
||||
- Create and run Shell and Ruby scripts
|
||||
- Shell (bash) is recommended for most tasks
|
||||
- Scripts can save their output to files
|
||||
- macOS-specific features:
|
||||
- AppleScript for system and UI control
|
||||
- Integration with macOS apps and services
|
||||
- Use the screenshot tool if needed to help with tasks
|
||||
|
||||
computer_control
|
||||
- System automation using AppleScript
|
||||
- Consider the screenshot tool to work out what is on screen and what to do to help with the control task.
|
||||
|
||||
When you need to interact with websites or web applications, consider using the computer_control tool with AppleScript, which can automate Safari or other browsers to:
|
||||
- Open specific URLs
|
||||
- Fill in forms
|
||||
@@ -205,47 +271,25 @@ impl ComputerControllerRouter {
|
||||
- Extract content
|
||||
- Handle web-based workflows
|
||||
This is often more reliable than web scraping for modern web applications.
|
||||
"#}
|
||||
} else {
|
||||
""
|
||||
"#},
|
||||
};
|
||||
|
||||
let instructions = formatdoc! {r#"
|
||||
You are a helpful assistant to a power user who is not a professional developer, but you may use devleopment tools to help assist them.
|
||||
You are a helpful assistant to a power user who is not a professional developer, but you may use development tools to help assist them.
|
||||
The user may not know how to break down tasks, so you will need to ensure that you do, and run things in batches as needed.
|
||||
The ComputerControllerExtension helps you with common tasks like web scraping,
|
||||
data processing, and automation and computer control without requiring programming expertise,
|
||||
supplementing the Developer Extension.
|
||||
data processing, and automation without requiring programming expertise.
|
||||
|
||||
You can use scripting as needed to work with text files of data, such as csvs, json, or text files etc.
|
||||
Using the developer extension is allowed for more sophisticated tasks or instructed to (js or py can be helpful for more complex tasks if tools are available).
|
||||
|
||||
{macos_instructions}
|
||||
|
||||
Accessing web sites, even apis, may be common (you can use bash scripting to do this) without troubling them too much (they won't know what limits are).
|
||||
Try to do your best to find ways to complete a task without too many quesitons or offering options unless it is really unclear, find a way if you can.
|
||||
Accessing web sites, even apis, may be common (you can use scripting to do this) without troubling them too much (they won't know what limits are).
|
||||
Try to do your best to find ways to complete a task without too many questions or offering options unless it is really unclear, find a way if you can.
|
||||
You can also guide them steps if they can help out as you go along.
|
||||
|
||||
There is already a screenshot tool available you can use if needed to see what is on screen.
|
||||
|
||||
Here are some extra tools:
|
||||
automation_script
|
||||
- Create and run simple automation scripts
|
||||
- Supports Shell (such as bash), AppleScript (on macos), Ruby (on macos)
|
||||
- Scripts can save their output to files
|
||||
- on macos, can use applescript to interact with the desktop, eg calendars, notes and more, anything apple script can do for apps that support it:
|
||||
AppleScript is a powerful scripting language designed for automating tasks on macOS such as: Integration with Other Scripts
|
||||
Execute shell scripts, Ruby scripts, or other automation scripts.
|
||||
Combine workflows across scripting languages.
|
||||
Complex Workflows
|
||||
Automate multi-step tasks involving multiple apps or system features.
|
||||
Create scheduled tasks using Calendar or other scheduling apps.
|
||||
|
||||
- use the screenshot tool if needed to help with tasks
|
||||
|
||||
computer_control
|
||||
- Control the computer using AppleScript (macOS only)
|
||||
- Consider the screenshot tool to work out what is on screen and what to do to help with the control task.
|
||||
{os_instructions}
|
||||
|
||||
web_search
|
||||
- Search the web using DuckDuckGo's API for general topics or keywords
|
||||
@@ -262,7 +306,7 @@ impl ComputerControllerRouter {
|
||||
- Cache directory: {cache_dir}
|
||||
- File organization and cleanup
|
||||
"#,
|
||||
macos_instructions = macos_browser_instructions,
|
||||
os_instructions = os_specific_instructions,
|
||||
cache_dir = cache_dir.display()
|
||||
};
|
||||
|
||||
@@ -278,6 +322,7 @@ impl ComputerControllerRouter {
|
||||
active_resources: Arc::new(Mutex::new(HashMap::new())),
|
||||
http_client: Client::builder().user_agent("Goose/1.0").build().unwrap(),
|
||||
instructions: instructions.clone(),
|
||||
system_automation,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -318,7 +363,7 @@ impl ComputerControllerRouter {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// Implement web_scrape tool functionality
|
||||
// Implement web_search tool functionality
|
||||
async fn web_search(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
||||
let query = params
|
||||
.get("query")
|
||||
@@ -452,22 +497,18 @@ impl ComputerControllerRouter {
|
||||
ToolError::ExecutionError(format!("Failed to create temporary directory: {}", e))
|
||||
})?;
|
||||
|
||||
let (shell, shell_arg) = self.system_automation.get_shell_command();
|
||||
|
||||
let command = match language {
|
||||
"shell" => {
|
||||
let script_path = script_dir.path().join("script.sh");
|
||||
"shell" | "batch" => {
|
||||
let script_path = script_dir.path().join(format!(
|
||||
"script.{}",
|
||||
if cfg!(windows) { "bat" } else { "sh" }
|
||||
));
|
||||
fs::write(&script_path, script).map_err(|e| {
|
||||
ToolError::ExecutionError(format!("Failed to write script: {}", e))
|
||||
})?;
|
||||
|
||||
fs::set_permissions(&script_path, fs::Permissions::from_mode(0o755)).map_err(
|
||||
|e| {
|
||||
ToolError::ExecutionError(format!(
|
||||
"Failed to set script permissions: {}",
|
||||
e
|
||||
))
|
||||
},
|
||||
)?;
|
||||
|
||||
script_path.display().to_string()
|
||||
}
|
||||
"ruby" => {
|
||||
@@ -478,12 +519,23 @@ impl ComputerControllerRouter {
|
||||
|
||||
format!("ruby {}", script_path.display())
|
||||
}
|
||||
"powershell" => {
|
||||
let script_path = script_dir.path().join("script.ps1");
|
||||
fs::write(&script_path, script).map_err(|e| {
|
||||
ToolError::ExecutionError(format!("Failed to write script: {}", e))
|
||||
})?;
|
||||
|
||||
format!(
|
||||
"powershell -NoProfile -NonInteractive -File {}",
|
||||
script_path.display()
|
||||
)
|
||||
}
|
||||
_ => unreachable!(), // Prevented by enum in tool definition
|
||||
};
|
||||
|
||||
// Run the script
|
||||
let output = Command::new("bash")
|
||||
.arg("-c")
|
||||
let output = Command::new(shell)
|
||||
.arg(shell_arg)
|
||||
.arg(&command)
|
||||
.output()
|
||||
.await
|
||||
@@ -515,14 +567,8 @@ impl ComputerControllerRouter {
|
||||
Ok(vec![Content::text(result)])
|
||||
}
|
||||
|
||||
// Implement computer control (AppleScript) functionality
|
||||
// Implement computer control functionality
|
||||
async fn computer_control(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
||||
if std::env::consts::OS != "macos" {
|
||||
return Err(ToolError::ExecutionError(
|
||||
"Computer control (AppleScript) is only supported on macOS".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let script = params
|
||||
.get("script")
|
||||
.and_then(|v| v.as_str())
|
||||
@@ -533,44 +579,18 @@ impl ComputerControllerRouter {
|
||||
.and_then(|v| v.as_bool())
|
||||
.unwrap_or(false);
|
||||
|
||||
// Create a temporary directory for the script
|
||||
let script_dir = tempfile::tempdir().map_err(|e| {
|
||||
ToolError::ExecutionError(format!("Failed to create temporary directory: {}", e))
|
||||
})?;
|
||||
// Use platform-specific automation
|
||||
let output = self
|
||||
.system_automation
|
||||
.execute_system_script(script)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to execute script: {}", e)))?;
|
||||
|
||||
let script_path = script_dir.path().join("script.scpt");
|
||||
fs::write(&script_path, script)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to write script: {}", e)))?;
|
||||
|
||||
let command = format!("osascript {}", script_path.display());
|
||||
|
||||
// Run the script
|
||||
let output = Command::new("bash")
|
||||
.arg("-c")
|
||||
.arg(&command)
|
||||
.output()
|
||||
.await
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to run AppleScript: {}", e)))?;
|
||||
|
||||
let output_str = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
let error_str = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
|
||||
let mut result = if output.status.success() {
|
||||
format!(
|
||||
"AppleScript completed successfully.\n\nOutput:\n{}",
|
||||
output_str
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"AppleScript failed with error code {}.\n\nError:\n{}\nOutput:\n{}",
|
||||
output.status, error_str, output_str
|
||||
)
|
||||
};
|
||||
let mut result = format!("Script completed successfully.\n\nOutput:\n{}", output);
|
||||
|
||||
// Save output if requested
|
||||
if save_output && !output_str.is_empty() {
|
||||
if save_output && !output.is_empty() {
|
||||
let cache_path = self
|
||||
.save_to_cache(output_str.as_bytes(), "applescript_output", "txt")
|
||||
.save_to_cache(output.as_bytes(), "automation_output", "txt")
|
||||
.await?;
|
||||
result.push_str(&format!("\n\nOutput saved to: {}", cache_path.display()));
|
||||
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
use super::SystemAutomation;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct MacOSAutomation;
|
||||
|
||||
// MacOSAutomation is Send + Sync because it contains no shared state
|
||||
unsafe impl Send for MacOSAutomation {}
|
||||
unsafe impl Sync for MacOSAutomation {}
|
||||
|
||||
impl SystemAutomation for MacOSAutomation {
|
||||
fn execute_system_script(&self, script: &str) -> std::io::Result<String> {
|
||||
let output = Command::new("osascript").arg("-e").arg(script).output()?;
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn get_shell_command(&self) -> (&'static str, &'static str) {
|
||||
("bash", "-c")
|
||||
}
|
||||
|
||||
fn get_temp_path(&self) -> PathBuf {
|
||||
PathBuf::from("/tmp")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
mod macos;
|
||||
mod windows;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub use self::windows::WindowsAutomation;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use self::macos::MacOSAutomation;
|
||||
|
||||
pub trait SystemAutomation: Send + Sync {
|
||||
fn execute_system_script(&self, script: &str) -> std::io::Result<String>;
|
||||
fn get_shell_command(&self) -> (&'static str, &'static str); // (shell, arg)
|
||||
fn get_temp_path(&self) -> std::path::PathBuf;
|
||||
}
|
||||
|
||||
pub fn create_system_automation() -> Box<dyn SystemAutomation + Send + Sync> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Box::new(WindowsAutomation)
|
||||
}
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Box::new(MacOSAutomation)
|
||||
}
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
{
|
||||
unimplemented!("Unsupported operating system")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
use super::SystemAutomation;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct WindowsAutomation;
|
||||
|
||||
// WindowsAutomation is Send + Sync because it contains no shared state
|
||||
unsafe impl Send for WindowsAutomation {}
|
||||
unsafe impl Sync for WindowsAutomation {}
|
||||
|
||||
impl SystemAutomation for WindowsAutomation {
|
||||
fn execute_system_script(&self, script: &str) -> std::io::Result<String> {
|
||||
let output = Command::new("powershell")
|
||||
.arg("-NoProfile")
|
||||
.arg("-NonInteractive")
|
||||
.arg("-Command")
|
||||
.arg(script)
|
||||
.output()?;
|
||||
|
||||
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
|
||||
}
|
||||
|
||||
fn get_shell_command(&self) -> (&'static str, &'static str) {
|
||||
("powershell", "-Command")
|
||||
}
|
||||
|
||||
fn get_temp_path(&self) -> PathBuf {
|
||||
std::env::var("TEMP")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| PathBuf::from(r"C:\Windows\Temp"))
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,9 @@ pub fn get_language_identifier(path: &Path) -> &'static str {
|
||||
Some("toml") => "toml",
|
||||
Some("yaml") | Some("yml") => "yaml",
|
||||
Some("sh") => "bash",
|
||||
Some("ps1") => "powershell",
|
||||
Some("bat") | Some("cmd") => "batch",
|
||||
Some("vbs") => "vbscript",
|
||||
Some("go") => "go",
|
||||
Some("md") => "markdown",
|
||||
Some("html") => "html",
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
mod lang;
|
||||
mod shell;
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine;
|
||||
@@ -31,6 +32,11 @@ use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use xcap::{Monitor, Window};
|
||||
|
||||
use self::shell::{
|
||||
expand_path, format_command_for_platform, get_shell_config, is_absolute_path,
|
||||
normalize_line_endings,
|
||||
};
|
||||
|
||||
pub struct DeveloperRouter {
|
||||
tools: Vec<Tool>,
|
||||
file_history: Arc<Mutex<HashMap<PathBuf, Vec<String>>>>,
|
||||
@@ -48,9 +54,30 @@ impl DeveloperRouter {
|
||||
// TODO consider rust native search tools, we could use
|
||||
// https://docs.rs/ignore/latest/ignore/
|
||||
|
||||
let bash_tool = Tool::new(
|
||||
"shell".to_string(),
|
||||
indoc! {r#"
|
||||
// Get OS-specific shell tool description
|
||||
let shell_tool_desc = match std::env::consts::OS {
|
||||
"windows" => indoc! {r#"
|
||||
Execute a command in the shell.
|
||||
|
||||
This will return the output and error concatenated into a single string, as
|
||||
you would see from running on the command line. There will also be an indication
|
||||
of if the command succeeded or failed.
|
||||
|
||||
Avoid commands that produce a large amount of output, and consider piping those outputs to files.
|
||||
|
||||
**Important**: For searching files and code:
|
||||
|
||||
Preferred: Use ripgrep (`rg`) when available - it respects .gitignore and is fast:
|
||||
- To locate a file by name: `rg --files | rg example.py`
|
||||
- To locate content inside files: `rg 'class Example'`
|
||||
|
||||
Alternative Windows commands (if ripgrep is not installed):
|
||||
- To locate a file by name: `dir /s /b example.py`
|
||||
- To locate content inside files: `findstr /s /i "class Example" *.py`
|
||||
|
||||
Note: Alternative commands may show ignored/hidden files that should be excluded.
|
||||
"#},
|
||||
_ => indoc! {r#"
|
||||
Execute a command in the shell.
|
||||
|
||||
This will return the output and error concatenated into a single string, as
|
||||
@@ -64,8 +91,13 @@ impl DeveloperRouter {
|
||||
**Important**: Use ripgrep - `rg` - when you need to locate a file or a code reference, other solutions
|
||||
may show ignored or hidden files. For example *do not* use `find` or `ls -r`
|
||||
- To locate a file by name: `rg --files | rg example.py`
|
||||
- To locate consent inside files: `rg 'class Example'`
|
||||
"#}.to_string(),
|
||||
- To locate content inside files: `rg 'class Example'`
|
||||
"#},
|
||||
};
|
||||
|
||||
let bash_tool = Tool::new(
|
||||
"shell".to_string(),
|
||||
shell_tool_desc.to_string(),
|
||||
json!({
|
||||
"type": "object",
|
||||
"required": ["command"],
|
||||
@@ -157,9 +189,31 @@ impl DeveloperRouter {
|
||||
|
||||
// Get base instructions and working directory
|
||||
let cwd = std::env::current_dir().expect("should have a current working dir");
|
||||
let base_instructions = formatdoc! {r#"
|
||||
The developer extension gives you the capabilities to edit code files and run shell commands,
|
||||
and can be used to solve a wide range of problems.
|
||||
let os = std::env::consts::OS;
|
||||
|
||||
let base_instructions = match os {
|
||||
"windows" => formatdoc! {r#"
|
||||
The developer extension gives you the capabilities to edit code files and run shell commands,
|
||||
and can be used to solve a wide range of problems.
|
||||
|
||||
You can use the shell tool to run Windows commands (PowerShell or CMD).
|
||||
When using paths, you can use either backslashes or forward slashes.
|
||||
|
||||
Use the shell tool as needed to locate files or interact with the project.
|
||||
|
||||
Your windows/screen tools can be used for visual debugging. You should not use these tools unless
|
||||
prompted to, but you can mention they are available if they are relevant.
|
||||
|
||||
operating system: {os}
|
||||
current directory: {cwd}
|
||||
|
||||
"#,
|
||||
os=os,
|
||||
cwd=cwd.to_string_lossy(),
|
||||
},
|
||||
_ => formatdoc! {r#"
|
||||
The developer extension gives you the capabilities to edit code files and run shell commands,
|
||||
and can be used to solve a wide range of problems.
|
||||
|
||||
You can use the shell tool to run any command that would work on the relevant operating system.
|
||||
Use the shell tool as needed to locate files or interact with the project.
|
||||
@@ -170,9 +224,10 @@ impl DeveloperRouter {
|
||||
operating system: {os}
|
||||
current directory: {cwd}
|
||||
|
||||
"#,
|
||||
os=std::env::consts::OS,
|
||||
cwd=cwd.to_string_lossy(),
|
||||
"#,
|
||||
os=os,
|
||||
cwd=cwd.to_string_lossy(),
|
||||
},
|
||||
};
|
||||
|
||||
// Check for global hints in ~/.config/goose/.goosehints
|
||||
@@ -223,15 +278,15 @@ impl DeveloperRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to resolve a path relative to cwd
|
||||
// Helper method to resolve a path relative to cwd with platform-specific handling
|
||||
fn resolve_path(&self, path_str: &str) -> Result<PathBuf, ToolError> {
|
||||
let cwd = std::env::current_dir().expect("should have a current working dir");
|
||||
let expanded = shellexpand::tilde(path_str);
|
||||
let path = Path::new(expanded.as_ref());
|
||||
let expanded = expand_path(path_str);
|
||||
let path = Path::new(&expanded);
|
||||
|
||||
let suggestion = cwd.join(path);
|
||||
|
||||
match path.is_absolute() {
|
||||
match is_absolute_path(&expanded) {
|
||||
true => Ok(path.to_path_buf()),
|
||||
false => Err(ToolError::InvalidParameters(format!(
|
||||
"The path {} is not an absolute path, did you possibly mean {}?",
|
||||
@@ -241,7 +296,7 @@ impl DeveloperRouter {
|
||||
}
|
||||
}
|
||||
|
||||
// Implement bash tool functionality
|
||||
// Shell command execution with platform-specific handling
|
||||
async fn bash(&self, params: Value) -> Result<Vec<Content>, ToolError> {
|
||||
let command =
|
||||
params
|
||||
@@ -251,19 +306,17 @@ impl DeveloperRouter {
|
||||
"The command string is required".to_string(),
|
||||
))?;
|
||||
|
||||
// TODO consider command suggestions and safety rails
|
||||
// Get platform-specific shell configuration
|
||||
let shell_config = get_shell_config();
|
||||
let cmd_with_redirect = format_command_for_platform(command);
|
||||
|
||||
// TODO be more careful about backgrounding, revisit interleave
|
||||
// Redirect stderr to stdout to interleave outputs
|
||||
let cmd_with_redirect = format!("{} 2>&1", command);
|
||||
|
||||
// Execute the command
|
||||
let child = Command::new("bash")
|
||||
.stdout(Stdio::piped()) // These two pipes required to capture output later.
|
||||
// Execute the command using platform-specific shell
|
||||
let child = Command::new(&shell_config.executable)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.stdin(Stdio::null())
|
||||
.kill_on_drop(true) // Critical so that the command is killed when the agent.reply stream is interrupted.
|
||||
.arg("-c")
|
||||
.kill_on_drop(true)
|
||||
.arg(&shell_config.arg)
|
||||
.arg(cmd_with_redirect)
|
||||
.spawn()
|
||||
.map_err(|e| ToolError::ExecutionError(e.to_string()))?;
|
||||
@@ -417,12 +470,15 @@ impl DeveloperRouter {
|
||||
path: &PathBuf,
|
||||
file_text: &str,
|
||||
) -> Result<Vec<Content>, ToolError> {
|
||||
// Normalize line endings based on platform
|
||||
let normalized_text = normalize_line_endings(file_text);
|
||||
|
||||
// Write to the file
|
||||
std::fs::write(path, file_text)
|
||||
std::fs::write(path, normalized_text)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?;
|
||||
|
||||
// Try to detect the language from the file extension
|
||||
let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
|
||||
let language = lang::get_language_identifier(path);
|
||||
|
||||
// The assistant output does not show the file again because the content is already in the tool request
|
||||
// but we do show it to the user here
|
||||
@@ -478,13 +534,14 @@ impl DeveloperRouter {
|
||||
// Save history for undo
|
||||
self.save_file_history(path)?;
|
||||
|
||||
// Replace and write back
|
||||
// Replace and write back with platform-specific line endings
|
||||
let new_content = content.replace(old_str, new_str);
|
||||
std::fs::write(path, &new_content)
|
||||
let normalized_content = normalize_line_endings(&new_content);
|
||||
std::fs::write(path, &normalized_content)
|
||||
.map_err(|e| ToolError::ExecutionError(format!("Failed to write file: {}", e)))?;
|
||||
|
||||
// Try to detect the language from the file extension
|
||||
let language = path.extension().and_then(|ext| ext.to_str()).unwrap_or("");
|
||||
let language = lang::get_language_identifier(path);
|
||||
|
||||
// Show a snippet of the changed content with context
|
||||
const SNIPPET_LINES: usize = 4;
|
||||
@@ -811,65 +868,28 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
#[serial]
|
||||
async fn test_text_editor_size_limits() {
|
||||
// Create temp directory first so it stays in scope for the whole test
|
||||
let temp_dir = tempfile::tempdir().unwrap();
|
||||
std::env::set_current_dir(&temp_dir).unwrap();
|
||||
|
||||
// Get router after setting current directory
|
||||
#[cfg(windows)]
|
||||
async fn test_windows_specific_commands() {
|
||||
let router = get_router().await;
|
||||
|
||||
// Test file size limit
|
||||
{
|
||||
let large_file_path = temp_dir.path().join("large.txt");
|
||||
let large_file_str = large_file_path.to_str().unwrap();
|
||||
// Test PowerShell command
|
||||
let result = router
|
||||
.call_tool(
|
||||
"shell",
|
||||
json!({
|
||||
"command": "Get-ChildItem"
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
assert!(result.is_ok());
|
||||
|
||||
// Create a file larger than 2MB
|
||||
let content = "x".repeat(3 * 1024 * 1024); // 3MB
|
||||
std::fs::write(&large_file_path, content).unwrap();
|
||||
// Test Windows path handling
|
||||
let result = router.resolve_path("C:\\Windows\\System32");
|
||||
assert!(result.is_ok());
|
||||
|
||||
let result = router
|
||||
.call_tool(
|
||||
"text_editor",
|
||||
json!({
|
||||
"command": "view",
|
||||
"path": large_file_str
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.err().unwrap();
|
||||
assert!(matches!(err, ToolError::ExecutionError(_)));
|
||||
assert!(err.to_string().contains("too large"));
|
||||
}
|
||||
|
||||
// Test character count limit
|
||||
{
|
||||
let many_chars_path = temp_dir.path().join("many_chars.txt");
|
||||
let many_chars_str = many_chars_path.to_str().unwrap();
|
||||
|
||||
// Create a file with more than 400K characters but less than 400KB
|
||||
let content = "x".repeat(405_000);
|
||||
std::fs::write(&many_chars_path, content).unwrap();
|
||||
|
||||
let result = router
|
||||
.call_tool(
|
||||
"text_editor",
|
||||
json!({
|
||||
"command": "view",
|
||||
"path": many_chars_str
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
|
||||
assert!(result.is_err());
|
||||
let err = result.err().unwrap();
|
||||
assert!(matches!(err, ToolError::ExecutionError(_)));
|
||||
assert!(err.to_string().contains("too many characters"));
|
||||
}
|
||||
|
||||
// Let temp_dir drop naturally at end of scope
|
||||
// Test UNC path handling
|
||||
let result = router.resolve_path("\\\\server\\share");
|
||||
assert!(result.is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
use std::env;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ShellConfig {
|
||||
pub executable: String,
|
||||
pub arg: String,
|
||||
pub redirect_syntax: String,
|
||||
}
|
||||
|
||||
impl Default for ShellConfig {
|
||||
fn default() -> Self {
|
||||
if cfg!(windows) {
|
||||
// Use cmd.exe for simpler command execution
|
||||
Self {
|
||||
executable: "cmd.exe".to_string(),
|
||||
arg: "/C".to_string(),
|
||||
redirect_syntax: "2>&1".to_string(), // cmd.exe also supports this syntax
|
||||
}
|
||||
} else {
|
||||
Self {
|
||||
executable: "bash".to_string(),
|
||||
arg: "-c".to_string(),
|
||||
redirect_syntax: "2>&1".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_shell_config() -> ShellConfig {
|
||||
ShellConfig::default()
|
||||
}
|
||||
|
||||
pub fn format_command_for_platform(command: &str) -> String {
|
||||
let config = get_shell_config();
|
||||
// For all shells, no braces needed
|
||||
format!("{} {}", command, config.redirect_syntax)
|
||||
}
|
||||
|
||||
pub fn expand_path(path_str: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
// Expand Windows environment variables (%VAR%)
|
||||
let with_userprofile = path_str.replace(
|
||||
"%USERPROFILE%",
|
||||
&env::var("USERPROFILE").unwrap_or_default(),
|
||||
);
|
||||
// Add more Windows environment variables as needed
|
||||
with_userprofile.replace("%APPDATA%", &env::var("APPDATA").unwrap_or_default())
|
||||
} else {
|
||||
// Unix-style expansion
|
||||
shellexpand::tilde(path_str).into_owned()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_absolute_path(path_str: &str) -> bool {
|
||||
if cfg!(windows) {
|
||||
// Check for Windows absolute paths (drive letters and UNC)
|
||||
path_str.contains(":\\") || path_str.starts_with("\\\\")
|
||||
} else {
|
||||
// Unix absolute paths start with /
|
||||
path_str.starts_with('/')
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_line_endings(text: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
// Ensure CRLF line endings on Windows
|
||||
text.replace("\r\n", "\n").replace("\n", "\r\n")
|
||||
} else {
|
||||
// Ensure LF line endings on Unix
|
||||
text.replace("\r\n", "\n")
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,12 @@ use goose::tracing::langfuse_layer;
|
||||
/// Returns the directory where log files should be stored.
|
||||
/// Creates the directory structure if it doesn't exist.
|
||||
fn get_log_directory() -> Result<PathBuf> {
|
||||
let home = std::env::var("HOME").context("HOME environment variable not set")?;
|
||||
let home = if cfg!(windows) {
|
||||
std::env::var("USERPROFILE").context("USERPROFILE environment variable not set")?
|
||||
} else {
|
||||
std::env::var("HOME").context("HOME environment variable not set")?
|
||||
};
|
||||
|
||||
let base_log_dir = PathBuf::from(home)
|
||||
.join(".config")
|
||||
.join("goose")
|
||||
|
||||
@@ -66,6 +66,9 @@ aws-config = { version = "1.1.7", features = ["behavior-version-latest"] }
|
||||
aws-smithy-types = "1.2.12"
|
||||
aws-sdk-bedrockruntime = "1.72.0"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
winapi = { version = "0.3", features = ["wincred"] }
|
||||
|
||||
[dev-dependencies]
|
||||
criterion = "0.5"
|
||||
tempfile = "3.15.0"
|
||||
|
||||
@@ -32,7 +32,11 @@ struct TokenCache {
|
||||
|
||||
fn get_base_path() -> PathBuf {
|
||||
const BASE_PATH: &str = ".config/goose/databricks/oauth";
|
||||
let home_dir = std::env::var("HOME").expect("HOME environment variable not set");
|
||||
let home_dir = if cfg!(windows) {
|
||||
std::env::var("USERPROFILE").expect("USERPROFILE environment variable not set")
|
||||
} else {
|
||||
std::env::var("HOME").expect("HOME environment variable not set")
|
||||
};
|
||||
PathBuf::from(home_dir).join(BASE_PATH)
|
||||
}
|
||||
|
||||
|
||||
@@ -196,15 +196,24 @@ impl StdioTransport {
|
||||
}
|
||||
|
||||
async fn spawn_process(&self) -> Result<(Child, ChildStdin, ChildStdout, ChildStderr), Error> {
|
||||
let mut process = Command::new(&self.command)
|
||||
let mut command = Command::new(&self.command);
|
||||
command
|
||||
.envs(&self.env)
|
||||
.args(&self.args)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::piped())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.kill_on_drop(true)
|
||||
// 0 sets the process group ID equal to the process ID
|
||||
.process_group(0) // don't inherit signal handling from parent process
|
||||
.kill_on_drop(true);
|
||||
|
||||
// Set process group only on Unix systems
|
||||
#[cfg(unix)]
|
||||
command.process_group(0); // don't inherit signal handling from parent process
|
||||
|
||||
// Hide console window on Windows
|
||||
#[cfg(windows)]
|
||||
command.creation_flags(0x08000000); // CREATE_NO_WINDOW flag
|
||||
|
||||
let mut process = command
|
||||
.spawn()
|
||||
.map_err(|e| Error::StdioProcessError(e.to_string()))?;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user