From bfa1baf431aa5f8aef43f404092e9cb38460846e Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Fri, 13 Feb 2026 13:16:45 -0800 Subject: [PATCH 1/2] added cmd to validate bundled extensions json --- crates/goose-cli/src/cli.rs | 29 ++ crates/goose-server/src/main.rs | 25 ++ crates/goose/src/agents/extension.rs | 10 + crates/goose/src/agents/mod.rs | 1 + .../goose/src/agents/validate_extensions.rs | 322 ++++++++++++++++++ 5 files changed, 387 insertions(+) create mode 100644 crates/goose/src/agents/validate_extensions.rs diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 3b1afd547a68..6133e4e85258 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,24 @@ 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; + let result = validate_bundled_extensions(&file)?; + if result.is_ok() { + println!("✓ All {} extensions validated successfully.", result.total); + Ok(()) + } else { + eprintln!( + "✗ Found {} error(s) in {} extensions:\n", + result.errors.len(), + result.total + ); + for err in &result.errors { + eprintln!(" {}", err); + } + 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..e84a36f344b1 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,22 @@ async fn main() -> anyhow::Result<()> { } } } + Commands::ValidateExtensions { path } => { + let result = validate_extensions::validate_bundled_extensions(&path)?; + if result.errors.is_empty() { + println!("✓ All {} extensions validated successfully.", result.total); + } else { + eprintln!( + "✗ Found {} error(s) in {} extension(s):\n", + result.errors.len(), + result.errors.len() + ); + for (i, error) in result.errors.iter().enumerate() { + eprintln!(" [{}] {}", i, error); + } + std::process::exit(1); + } + } } Ok(()) diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index 5e460ba4b370..ffd9b4f59748 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -374,6 +374,16 @@ impl ExtensionConfig { } } + pub const VALID_TYPES: &[&str] = &[ + "sse", + "stdio", + "builtin", + "platform", + "streamable_http", + "frontend", + "inline_python", + ]; + pub fn key(&self) -> String { name_to_key(&self.name()) } diff --git a/crates/goose/src/agents/mod.rs b/crates/goose/src/agents/mod.rs index 268bfe29031f..df6dd5a07ad7 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}; 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..df28ede2cb56 --- /dev/null +++ b/crates/goose/src/agents/validate_extensions.rs @@ -0,0 +1,322 @@ +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, +} + +#[derive(Debug)] +pub struct ValidationError { + pub index: usize, + pub id: String, + pub name: String, + pub error: String, +} + +impl std::fmt::Display for ValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "[{}] {} (id={}): {}", + self.index, self.name, self.id, self.error + ) + } +} + +#[derive(Debug)] +pub struct ValidationResult { + pub total: usize, + pub errors: Vec, +} + +impl ValidationResult { + pub fn is_ok(&self) -> bool { + self.errors.is_empty() + } +} + +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::new(); + + for (index, entry) in raw_entries.iter().enumerate() { + let meta: BundledExtensionEntry = match serde_json::from_value(entry.clone()) { + Ok(m) => m, + Err(e) => { + errors.push(ValidationError { + index, + id: entry + .get("id") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + name: entry + .get("name") + .and_then(|v| v.as_str()) + .unwrap_or("unknown") + .to_string(), + error: format!("missing required metadata fields: {e}"), + }); + continue; + } + }; + + if !ExtensionConfig::VALID_TYPES.contains(&meta.extension_type.as_str()) { + errors.push(ValidationError { + index, + id: meta.id, + name: meta.name, + error: format!( + "unknown type \"{}\", expected one of: {}", + meta.extension_type, + ExtensionConfig::VALID_TYPES.join(", ") + ), + }); + continue; + } + + let mut has_type_error = false; + match meta.extension_type.as_str() { + "streamable_http" => { + if entry.get("url").is_some() && entry.get("uri").is_none() { + errors.push(ValidationError { + index, + id: meta.id.clone(), + name: meta.name.clone(), + error: "has \"url\" field but streamable_http expects \"uri\" — did you mean \"uri\"?".to_string(), + }); + has_type_error = true; + } + } + "stdio" => { + if entry.get("cmd").is_none() { + errors.push(ValidationError { + index, + id: meta.id.clone(), + name: meta.name.clone(), + error: "stdio extension is missing required \"cmd\" field".to_string(), + }); + has_type_error = true; + } + } + _ => {} + } + + if !has_type_error { + if let Err(e) = serde_json::from_value::(entry.clone()) { + errors.push(ValidationError { + index, + id: meta.id, + name: meta.name, + error: format!("failed to deserialize as ExtensionConfig: {e}"), + }); + } + } + } + + Ok(ValidationResult { total, errors }) +} + +#[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()).unwrap(); + assert!(result.is_ok()); + assert_eq!(result.total, 1); + } + + #[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()).unwrap(); + 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()).unwrap(); + 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()).unwrap(); + assert!(!result.is_ok()); + assert_eq!(result.errors.len(), 1); + assert!(result.errors[0].error.contains("unknown type \"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()).unwrap(); + assert!(!result.is_ok()); + assert!(result.errors.iter().any(|e| e.error.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()).unwrap(); + assert!(!result.is_ok()); + assert!(result.errors[0].error.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()).unwrap(); + assert_eq!(result.total, 2); + assert_eq!(result.errors.len(), 1); + assert_eq!(result.errors[0].index, 1); + assert_eq!(result.errors[0].id, "bad"); + } + + #[test] + fn test_empty_array_is_valid() { + let f = write_json("[]"); + let result = validate_bundled_extensions(f.path()).unwrap(); + assert!(result.is_ok()); + assert_eq!(result.total, 0); + } + + #[test] + fn test_valid_types_all_deserialize() { + for type_name in ExtensionConfig::VALID_TYPES { + let json = serde_json::json!({ + "type": type_name, + "name": "test", + "description": "test", + "cmd": "echo", + "args": [], + "uri": "https://example.com", + "code": "print('hi')", + "tools": [], + }); + let result = serde_json::from_value::(json); + assert!( + result.is_ok(), + "VALID_TYPES contains \"{}\" but it failed to deserialize: {}", + type_name, + result.unwrap_err() + ); + } + } +} From 5a07529c4ab1914a25b4cd1126d2d1894f2b8eea Mon Sep 17 00:00:00 2001 From: Zane Staggs Date: Tue, 17 Feb 2026 08:48:23 -0800 Subject: [PATCH 2/2] remove valid_types constant and fix Deduplicate output formatting --- crates/goose-cli/src/cli.rs | 21 +- crates/goose-server/src/main.rs | 17 +- crates/goose/src/agents/extension.rs | 10 - .../goose/src/agents/validate_extensions.rs | 197 ++++++------------ 4 files changed, 74 insertions(+), 171 deletions(-) diff --git a/crates/goose-cli/src/cli.rs b/crates/goose-cli/src/cli.rs index 5c4ba29c5c6c..da67adcd73b0 100644 --- a/crates/goose-cli/src/cli.rs +++ b/crates/goose-cli/src/cli.rs @@ -1532,20 +1532,15 @@ pub async fn cli() -> anyhow::Result<()> { Some(Command::Term { command }) => handle_term_subcommand(command).await, Some(Command::ValidateExtensions { file }) => { use goose::agents::validate_extensions::validate_bundled_extensions; - let result = validate_bundled_extensions(&file)?; - if result.is_ok() { - println!("✓ All {} extensions validated successfully.", result.total); - Ok(()) - } else { - eprintln!( - "✗ Found {} error(s) in {} extensions:\n", - result.errors.len(), - result.total - ); - for err in &result.errors { - eprintln!(" {}", err); + match validate_bundled_extensions(&file) { + Ok(msg) => { + println!("{msg}"); + Ok(()) + } + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); } - 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 e84a36f344b1..c2cea46be852 100644 --- a/crates/goose-server/src/main.rs +++ b/crates/goose-server/src/main.rs @@ -69,19 +69,12 @@ async fn main() -> anyhow::Result<()> { } } Commands::ValidateExtensions { path } => { - let result = validate_extensions::validate_bundled_extensions(&path)?; - if result.errors.is_empty() { - println!("✓ All {} extensions validated successfully.", result.total); - } else { - eprintln!( - "✗ Found {} error(s) in {} extension(s):\n", - result.errors.len(), - result.errors.len() - ); - for (i, error) in result.errors.iter().enumerate() { - eprintln!(" [{}] {}", i, error); + match validate_extensions::validate_bundled_extensions(&path) { + Ok(msg) => println!("{msg}"), + Err(e) => { + eprintln!("{e}"); + std::process::exit(1); } - std::process::exit(1); } } } diff --git a/crates/goose/src/agents/extension.rs b/crates/goose/src/agents/extension.rs index ffd9b4f59748..5e460ba4b370 100644 --- a/crates/goose/src/agents/extension.rs +++ b/crates/goose/src/agents/extension.rs @@ -374,16 +374,6 @@ impl ExtensionConfig { } } - pub const VALID_TYPES: &[&str] = &[ - "sse", - "stdio", - "builtin", - "platform", - "streamable_http", - "frontend", - "inline_python", - ]; - pub fn key(&self) -> String { name_to_key(&self.name()) } diff --git a/crates/goose/src/agents/validate_extensions.rs b/crates/goose/src/agents/validate_extensions.rs index df28ede2cb56..3952daec3986 100644 --- a/crates/goose/src/agents/validate_extensions.rs +++ b/crates/goose/src/agents/validate_extensions.rs @@ -14,118 +14,65 @@ struct BundledExtensionEntry { enabled: bool, } -#[derive(Debug)] -pub struct ValidationError { - pub index: usize, - pub id: String, - pub name: String, - pub error: String, -} - -impl std::fmt::Display for ValidationError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!( - f, - "[{}] {} (id={}): {}", - self.index, self.name, self.id, self.error - ) - } -} - -#[derive(Debug)] -pub struct ValidationResult { - pub total: usize, - pub errors: Vec, -} - -impl ValidationResult { - pub fn is_ok(&self) -> bool { - self.errors.is_empty() - } -} - -pub fn validate_bundled_extensions(path: &Path) -> Result { +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::new(); + 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) => { - errors.push(ValidationError { - index, - id: entry - .get("id") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(), - name: entry - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(), - error: format!("missing required metadata fields: {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; } }; - if !ExtensionConfig::VALID_TYPES.contains(&meta.extension_type.as_str()) { - errors.push(ValidationError { - index, - id: meta.id, - name: meta.name, - error: format!( - "unknown type \"{}\", expected one of: {}", - meta.extension_type, - ExtensionConfig::VALID_TYPES.join(", ") - ), - }); + // 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; } - let mut has_type_error = false; - match meta.extension_type.as_str() { - "streamable_http" => { - if entry.get("url").is_some() && entry.get("uri").is_none() { - errors.push(ValidationError { - index, - id: meta.id.clone(), - name: meta.name.clone(), - error: "has \"url\" field but streamable_http expects \"uri\" — did you mean \"uri\"?".to_string(), - }); - has_type_error = true; - } - } - "stdio" => { - if entry.get("cmd").is_none() { - errors.push(ValidationError { - index, - id: meta.id.clone(), - name: meta.name.clone(), - error: "stdio extension is missing required \"cmd\" field".to_string(), - }); - has_type_error = true; - } - } - _ => {} + 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 !has_type_error { - if let Err(e) = serde_json::from_value::(entry.clone()) { - errors.push(ValidationError { - index, - id: meta.id, - name: meta.name, - error: format!("failed to deserialize as ExtensionConfig: {e}"), - }); - } + if let Err(e) = serde_json::from_value::(entry.clone()) { + errors.push(format!("[{index}] {} (id={}): {e}", meta.name, meta.id)); } } - Ok(ValidationResult { total, errors }) + 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)] @@ -154,9 +101,9 @@ mod tests { "bundled": true }]"#, ); - let result = validate_bundled_extensions(f.path()).unwrap(); + let result = validate_bundled_extensions(f.path()); assert!(result.is_ok()); - assert_eq!(result.total, 1); + assert!(result.unwrap().contains("1 extensions validated")); } #[test] @@ -175,7 +122,7 @@ mod tests { "bundled": true }]"#, ); - let result = validate_bundled_extensions(f.path()).unwrap(); + let result = validate_bundled_extensions(f.path()); assert!(result.is_ok()); } @@ -195,7 +142,7 @@ mod tests { "bundled": true }]"#, ); - let result = validate_bundled_extensions(f.path()).unwrap(); + let result = validate_bundled_extensions(f.path()); assert!(result.is_ok()); } @@ -213,10 +160,11 @@ mod tests { "bundled": true }]"#, ); - let result = validate_bundled_extensions(f.path()).unwrap(); - assert!(!result.is_ok()); - assert_eq!(result.errors.len(), 1); - assert!(result.errors[0].error.contains("unknown type \"http\"")); + 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] @@ -233,9 +181,9 @@ mod tests { "bundled": true }]"#, ); - let result = validate_bundled_extensions(f.path()).unwrap(); - assert!(!result.is_ok()); - assert!(result.errors.iter().any(|e| e.error.contains("uri"))); + let result = validate_bundled_extensions(f.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("uri")); } #[test] @@ -252,9 +200,9 @@ mod tests { "bundled": true }]"#, ); - let result = validate_bundled_extensions(f.path()).unwrap(); - assert!(!result.is_ok()); - assert!(result.errors[0].error.contains("cmd")); + let result = validate_bundled_extensions(f.path()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("cmd")); } #[test] @@ -282,41 +230,18 @@ mod tests { } ]"#, ); - let result = validate_bundled_extensions(f.path()).unwrap(); - assert_eq!(result.total, 2); - assert_eq!(result.errors.len(), 1); - assert_eq!(result.errors[0].index, 1); - assert_eq!(result.errors[0].id, "bad"); + 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()).unwrap(); + let result = validate_bundled_extensions(f.path()); assert!(result.is_ok()); - assert_eq!(result.total, 0); - } - - #[test] - fn test_valid_types_all_deserialize() { - for type_name in ExtensionConfig::VALID_TYPES { - let json = serde_json::json!({ - "type": type_name, - "name": "test", - "description": "test", - "cmd": "echo", - "args": [], - "uri": "https://example.com", - "code": "print('hi')", - "tools": [], - }); - let result = serde_json::from_value::(json); - assert!( - result.is_ok(), - "VALID_TYPES contains \"{}\" but it failed to deserialize: {}", - type_name, - result.unwrap_err() - ); - } + assert!(result.unwrap().contains("0 extensions validated")); } }