diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index a6cda3cc6a0d..da67adcd73b0 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -863,6 +863,16 @@ enum Command { #[arg(long, default_value = "goose", help = "Provide a custom binary name")] bin_name: String, }, + + #[command( + name = "validate-extensions", + about = "Validate a bundled-extensions.json file", + hide = true + )] + ValidateExtensions { + #[arg(help = "Path to the bundled-extensions.json file")] + file: PathBuf, + }, } #[derive(Subcommand)] @@ -955,6 +965,7 @@ fn get_command_name(command: &Option) -> &'static str { Some(Command::Web { .. }) => "web", Some(Command::Term { .. }) => "term", Some(Command::Completion { .. }) => "completion", + Some(Command::ValidateExtensions { .. }) => "validate-extensions", None => "default_session", } } @@ -1519,6 +1530,19 @@ pub async fn cli() -> anyhow::Result<()> { no_auth, }) => crate::commands::web::handle_web(port, host, open, auth_token, no_auth).await, Some(Command::Term { command }) => handle_term_subcommand(command).await, + Some(Command::ValidateExtensions { file }) => { + use goose::agents::validate_extensions::validate_bundled_extensions; + match validate_bundled_extensions(&file) { + Ok(msg) => { + println!("{msg}"); + Ok(()) + } + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + } + } None => handle_default_session().await, } } diff --git a/crates/goose-server/src/main.rs b/crates/goose-server/src/main.rs index ef4b6bc5d4a8..c2cea46be852 100644 --- a/crates/goose-server/src/main.rs +++ b/crates/goose-server/src/main.rs @@ -7,7 +7,10 @@ mod routes; mod state; mod tunnel; +use std::path::PathBuf; + use clap::{Parser, Subcommand}; +use goose::agents::validate_extensions; use goose::config::paths::Paths; use goose_mcp::{ mcp_server_runner::{serve, McpCommand}, @@ -31,6 +34,12 @@ enum Commands { #[arg(value_parser = clap::value_parser!(McpCommand))] server: McpCommand, }, + /// Validate a bundled-extensions JSON file + #[command(name = "validate-extensions")] + ValidateExtensions { + /// Path to the bundled-extensions JSON file + path: PathBuf, + }, } #[tokio::main] @@ -59,6 +68,15 @@ async fn main() -> anyhow::Result<()> { } } } + Commands::ValidateExtensions { path } => { + match validate_extensions::validate_bundled_extensions(&path) { + Ok(msg) => println!("{msg}"), + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); + } + } + } } Ok(()) diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 588f70e3e398..9f083b88bdb8 100644 --- a/crates/goose/src/agents/mod.rs +++ b/crates/goose/src/agents/mod.rs @@ -20,6 +20,7 @@ pub(crate) mod subagent_handler; pub(crate) mod subagent_task_config; mod tool_execution; pub mod types; +pub mod validate_extensions; pub use agent::{Agent, AgentConfig, AgentEvent, ExtensionLoadResult, GoosePlatform}; pub use container::Container; diff --git a/crates/goose/src/agents/validate_extensions.rs b/crates/goose/src/agents/validate_extensions.rs new file mode 100644 index 000000000000..3952daec3986 --- /dev/null +++ b/crates/goose/src/agents/validate_extensions.rs @@ -0,0 +1,247 @@ +use crate::agents::ExtensionConfig; +use anyhow::Result; +use serde::Deserialize; +use std::path::Path; + +#[derive(Debug, Deserialize)] +struct BundledExtensionEntry { + id: String, + name: String, + #[serde(rename = "type")] + extension_type: String, + #[allow(dead_code)] + #[serde(default)] + enabled: bool, +} + +pub fn validate_bundled_extensions(path: &Path) -> Result { + let content = std::fs::read_to_string(path)?; + let raw_entries: Vec = serde_json::from_str(&content)?; + let total = raw_entries.len(); + let mut errors: Vec = Vec::new(); + + for (index, entry) in raw_entries.iter().enumerate() { + let meta: BundledExtensionEntry = match serde_json::from_value(entry.clone()) { + Ok(m) => m, + Err(e) => { + let id = entry + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + let name = entry + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown"); + errors.push(format!( + "[{index}] {name} (id={id}): missing required metadata fields: {e}" + )); + continue; + } + }; + + // Check for common field name mistakes before full deserialization + if meta.extension_type == "streamable_http" + && entry.get("url").is_some() + && entry.get("uri").is_none() + { + errors.push(format!( + "[{index}] {} (id={}): has \"url\" field but streamable_http expects \"uri\" — did you mean \"uri\"?", + meta.name, meta.id + )); + continue; + } + + if meta.extension_type == "stdio" && entry.get("cmd").is_none() { + errors.push(format!( + "[{index}] {} (id={}): stdio extension is missing required \"cmd\" field", + meta.name, meta.id + )); + continue; + } + + if let Err(e) = serde_json::from_value::(entry.clone()) { + errors.push(format!("[{index}] {} (id={}): {e}", meta.name, meta.id)); + } + } + + if errors.is_empty() { + Ok(format!("✓ All {total} extensions validated successfully.")) + } else { + let mut output = format!("✗ Found {} error(s) in {total} extensions:\n", errors.len()); + for error in &errors { + output.push_str(&format!("\n {error}")); + } + anyhow::bail!("{output}"); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use tempfile::NamedTempFile; + + fn write_json(content: &str) -> NamedTempFile { + let mut f = NamedTempFile::new().unwrap(); + f.write_all(content.as_bytes()).unwrap(); + f + } + + #[test] + fn test_valid_builtin() { + let f = write_json( + r#"[{ + "id": "developer", + "name": "developer", + "display_name": "Developer", + "description": "Dev tools", + "enabled": true, + "type": "builtin", + "timeout": 300, + "bundled": true + }]"#, + ); + let result = validate_bundled_extensions(f.path()); + assert!(result.is_ok()); + assert!(result.unwrap().contains("1 extensions validated")); + } + + #[test] + fn test_valid_stdio() { + let f = write_json( + r#"[{ + "id": "googledrive", + "name": "Google Drive", + "description": "Google Drive integration", + "enabled": false, + "type": "stdio", + "cmd": "uvx", + "args": ["mcp_gdrive@latest"], + "env_keys": [], + "timeout": 300, + "bundled": true + }]"#, + ); + let result = validate_bundled_extensions(f.path()); + assert!(result.is_ok()); + } + + #[test] + fn test_valid_streamable_http() { + let f = write_json( + r#"[{ + "id": "asana", + "name": "Asana", + "display_name": "Asana", + "description": "Manage Asana tasks", + "enabled": false, + "type": "streamable_http", + "uri": "https://mcp.asana.com/mcp", + "env_keys": [], + "timeout": 300, + "bundled": true + }]"#, + ); + let result = validate_bundled_extensions(f.path()); + assert!(result.is_ok()); + } + + #[test] + fn test_invalid_type_http() { + let f = write_json( + r#"[{ + "id": "asana", + "name": "Asana", + "description": "Manage Asana tasks", + "enabled": false, + "type": "http", + "uri": "https://mcp.asana.com/mcp", + "timeout": 300, + "bundled": true + }]"#, + ); + let result = validate_bundled_extensions(f.path()); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Asana")); + assert!(err.contains("unknown variant `http`")); + } + + #[test] + fn test_url_instead_of_uri() { + let f = write_json( + r#"[{ + "id": "neighborhood", + "name": "Neighborhood", + "description": "Neighborhood tools", + "enabled": false, + "type": "streamable_http", + "url": "https://example.com/mcp", + "timeout": 300, + "bundled": true + }]"#, + ); + let result = validate_bundled_extensions(f.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("uri")); + } + + #[test] + fn test_missing_cmd_for_stdio() { + let f = write_json( + r#"[{ + "id": "test", + "name": "Test", + "description": "Test extension", + "enabled": false, + "type": "stdio", + "args": [], + "timeout": 300, + "bundled": true + }]"#, + ); + let result = validate_bundled_extensions(f.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cmd")); + } + + #[test] + fn test_valid_entries_before_invalid_still_pass() { + let f = write_json( + r#"[ + { + "id": "developer", + "name": "developer", + "description": "Dev tools", + "enabled": true, + "type": "builtin", + "timeout": 300, + "bundled": true + }, + { + "id": "bad", + "name": "Bad Extension", + "description": "This one is broken", + "enabled": false, + "type": "http", + "uri": "https://example.com", + "timeout": 300, + "bundled": true + } + ]"#, + ); + let result = validate_bundled_extensions(f.path()); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("1 error(s)")); + assert!(err.contains("Bad Extension")); + } + + #[test] + fn test_empty_array_is_valid() { + let f = write_json("[]"); + let result = validate_bundled_extensions(f.path()); + assert!(result.is_ok()); + assert!(result.unwrap().contains("0 extensions validated")); + } +}