mirror of
https://github.com/aaif-goose/goose.git
synced 2026-07-03 14:10:03 +02:00
Added cmd to validate bundled extensions json (#7217)
This commit is contained in:
@@ -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<Command>) -> &'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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String> {
|
||||
let content = std::fs::read_to_string(path)?;
|
||||
let raw_entries: Vec<serde_json::Value> = serde_json::from_str(&content)?;
|
||||
let total = raw_entries.len();
|
||||
let mut errors: Vec<String> = 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::<ExtensionConfig>(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"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user