From b7d168c91d35bc34f11a09f65da344225f0dba76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 13 Nov 2025 23:39:21 +0000 Subject: [PATCH 1/2] Add inspect call subcommand with args and policy file support Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- src/commands.rs | 38 ++++- src/main.rs | 294 ++++++++++++++++++++++++++++------ tests/cli_integration_test.rs | 205 +++++++++++++++++++++++- 3 files changed, 484 insertions(+), 53 deletions(-) diff --git a/src/commands.rs b/src/commands.rs index 65240889..0cf945ed 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -69,13 +69,10 @@ pub enum Commands { #[command(subcommand)] command: SecretCommands, }, - /// Inspect a WebAssembly component and display its JSON schema (for debugging). + /// Inspect a WebAssembly component (display schema or call component). Inspect { - /// Component ID to inspect - component_id: String, - /// Directory where components are stored. Defaults to $XDG_DATA_HOME/wassette/components - #[arg(long)] - component_dir: Option, + #[command(subcommand)] + command: InspectCommands, }, /// Manage tools (list, read, invoke). Tool { @@ -435,6 +432,35 @@ pub enum ToolCommands { }, } +#[derive(Subcommand, Debug)] +pub enum InspectCommands { + /// Display JSON schema of a component. + Schema { + /// Component ID to inspect + component_id: String, + /// Directory where components are stored. Defaults to $XDG_DATA_HOME/wassette/components + #[arg(long)] + component_dir: Option, + }, + /// Call a component with arguments. + Call { + /// Component ID to call + component_id: String, + /// Arguments in KEY=VALUE format. Can be specified multiple times. + #[arg(long = "arg", value_parser = crate::parse_env_var)] + args: Vec<(String, String)>, + /// Path to policy file for this component + #[arg(long)] + policy_file: Option, + /// Directory where components are stored. Defaults to $XDG_DATA_HOME/wassette/components + #[arg(long)] + component_dir: Option, + /// Output format + #[arg(short = 'o', long = "output-format", default_value = "json")] + output_format: OutputFormat, + }, +} + #[derive(Subcommand, Debug)] pub enum RegistryCommands { /// Search for components in the registry. diff --git a/src/main.rs b/src/main.rs index 5e4c144d..a6172376 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,7 +13,7 @@ use rmcp::service::serve_server; use rmcp::transport::streamable_http_server::session::local::LocalSessionManager; use rmcp::transport::streamable_http_server::StreamableHttpService; use rmcp::transport::{stdio as stdio_transport, SseServer}; -use serde_json::{json, Map}; +use serde_json::{json, Map, Value}; use tracing_subscriber::layer::SubscriberExt as _; use tracing_subscriber::util::SubscriberInitExt as _; @@ -31,8 +31,9 @@ mod utils; use cli_handlers::{create_lifecycle_manager, handle_tool_cli_command}; use commands::{ - Cli, Commands, ComponentCommands, GrantPermissionCommands, PermissionCommands, PolicyCommands, - RegistryCommands, RevokePermissionCommands, SecretCommands, Shell, ToolCommands, Transport, + Cli, Commands, ComponentCommands, GrantPermissionCommands, InspectCommands, PermissionCommands, + PolicyCommands, RegistryCommands, RevokePermissionCommands, SecretCommands, Shell, + ToolCommands, Transport, }; use format::{print_result, OutputFormat}; use server::McpServer; @@ -716,50 +717,253 @@ async fn main() -> Result<()> { } } }, - Commands::Inspect { - component_id, - component_dir, - } => { - let component_dir = component_dir.clone().or_else(|| cli.component_dir.clone()); - let lifecycle_manager = create_lifecycle_manager(component_dir).await?; - - // Get the component schema from the lifecycle manager - let schema = lifecycle_manager - .get_component_schema(component_id) - .await - .context(format!( - "Component '{}' not found. Use 'component load' to load the component first.", - component_id - ))?; - - // Display tools information - if let Some(arr) = schema["tools"].as_array() { - for t in arr { - // The tool info is nested in properties.result - let tool_info = &t["properties"]["result"]; - let name = tool_info["name"] - .as_str() - .unwrap_or("") - .to_string(); - let description: Option = - tool_info["description"].as_str().map(|s| s.to_string()); - let input_schema = tool_info["inputSchema"].clone(); - let output_schema = tool_info["outputSchema"].clone(); - - println!("{name}, {description:?}"); - println!( - "input schema: {}", - serde_json::to_string_pretty(&input_schema)? - ); - println!( - "output schema: {}", - serde_json::to_string_pretty(&output_schema)? - ); + Commands::Inspect { command } => match command { + InspectCommands::Schema { + component_id, + component_dir, + } => { + let component_dir = component_dir.clone().or_else(|| cli.component_dir.clone()); + let lifecycle_manager = create_lifecycle_manager(component_dir).await?; + + // Get the component schema from the lifecycle manager + let schema = lifecycle_manager + .get_component_schema(component_id) + .await + .context(format!( + "Component '{}' not found. Use 'component load' to load the component first.", + component_id + ))?; + + // Display tools information + if let Some(arr) = schema["tools"].as_array() { + for t in arr { + // The tool info is nested in properties.result + let tool_info = &t["properties"]["result"]; + let name = tool_info["name"] + .as_str() + .unwrap_or("") + .to_string(); + let description: Option = + tool_info["description"].as_str().map(|s| s.to_string()); + let input_schema = tool_info["inputSchema"].clone(); + let output_schema = tool_info["outputSchema"].clone(); + + println!("{name}, {description:?}"); + println!( + "input schema: {}", + serde_json::to_string_pretty(&input_schema)? + ); + println!( + "output schema: {}", + serde_json::to_string_pretty(&output_schema)? + ); + } + } else { + println!("No tools found in component"); } - } else { - println!("No tools found in component"); } - } + InspectCommands::Call { + component_id, + args, + policy_file, + component_dir, + output_format, + } => { + let component_dir = component_dir.clone().or_else(|| cli.component_dir.clone()); + let lifecycle_manager = create_lifecycle_manager(component_dir).await?; + + // Ensure the component is loaded in memory first + lifecycle_manager + .ensure_component_loaded(component_id) + .await + .context("Failed to load component into memory")?; + + // Apply policy file if provided + if let Some(policy_path) = policy_file { + // Read and apply the policy file + let policy_content = std::fs::read_to_string(policy_path) + .context("Failed to read policy file")?; + + // Parse the policy as YAML and convert to JSON for the grant-* tools + let policy: serde_json::Value = serde_yaml::from_str(&policy_content) + .context("Failed to parse policy file as YAML")?; + + // Apply the policy to the component + // This is a simplified version - the full implementation would need to + // parse the policy structure and call the appropriate grant-* functions + tracing::info!("Applying policy from file to component {}", component_id); + + // For now, we'll use the existing policy application logic + // The policy should contain permissions like storage, network, etc. + if let Some(permissions) = policy.get("permissions") { + // Apply storage permissions + if let Some(storage) = + permissions.get("storage").and_then(|v| v.as_array()) + { + for s in storage { + let uri = + s.get("uri").and_then(|v| v.as_str()).ok_or_else(|| { + anyhow::anyhow!("Storage permission missing 'uri'") + })?; + let access = s + .get("access") + .and_then(|v| v.as_array()) + .ok_or_else(|| { + anyhow::anyhow!("Storage permission missing 'access'") + })? + .iter() + .filter_map(|v| v.as_str().map(|s| s.to_string())) + .collect::>(); + + lifecycle_manager + .grant_permission( + component_id, + "storage", + &json!({ + "uri": uri, + "access": access + }), + ) + .await?; + } + } + + // Apply network permissions + if let Some(network) = + permissions.get("network").and_then(|v| v.as_array()) + { + for n in network { + let host = n.get("host").and_then(|v| v.as_str()).ok_or_else( + || anyhow::anyhow!("Network permission missing 'host'"), + )?; + + lifecycle_manager + .grant_permission( + component_id, + "network", + &json!({ + "host": host + }), + ) + .await?; + } + } + + // Apply environment variable permissions + if let Some(env_vars) = permissions + .get("environment-variables") + .and_then(|v| v.as_array()) + { + for e in env_vars { + let key = + e.get("key").and_then(|v| v.as_str()).ok_or_else(|| { + anyhow::anyhow!( + "Environment variable permission missing 'key'" + ) + })?; + + lifecycle_manager + .grant_permission( + component_id, + "environment-variable", + &json!({ + "key": key + }), + ) + .await?; + } + } + + // Apply memory permissions + if let Some(memory) = permissions.get("memory") { + if let Some(limit) = memory.get("limit").and_then(|v| v.as_str()) { + lifecycle_manager + .grant_permission( + component_id, + "memory", + &json!({ + "resources": { + "limits": { + "memory": limit + } + } + }), + ) + .await?; + } + } + } + } + + // Get the component schema to find the tool name + let schema = lifecycle_manager + .get_component_schema(component_id) + .await + .context(format!( + "Component '{}' not found. Use 'component load' to load the component first.", + component_id + ))?; + + // Find the first tool in the component + let tool_name = if let Some(arr) = schema["tools"].as_array() { + if let Some(first_tool) = arr.first() { + // Try nested structure first (live component schema) + let tool_info = if first_tool.get("properties").is_some() { + &first_tool["properties"]["result"] + } else { + // Fallback to flat structure (metadata schema) + first_tool + }; + + tool_info["name"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Tool name not found in schema"))? + .to_string() + } else { + bail!("No tools found in component"); + } + } else { + bail!("No tools found in component"); + }; + + // Convert args to JSON + let mut arguments = Map::new(); + for (key, value) in args { + // Try to parse value as JSON, otherwise treat as string + let json_value: Value = serde_json::from_str(value) + .unwrap_or_else(|_| Value::String(value.clone())); + arguments.insert(key.clone(), json_value); + } + + // Call the component + let parameters_json = serde_json::to_string(&arguments)?; + let result = lifecycle_manager + .execute_component_call(component_id, &tool_name, ¶meters_json) + .await; + + match result { + Ok(result_str) => { + let result_value: Value = serde_json::from_str(&result_str) + .unwrap_or_else(|_| Value::String(result_str.clone())); + + print_result( + &rmcp::model::CallToolResult { + content: Some(vec![rmcp::model::Content::text( + serde_json::to_string_pretty(&result_value)?, + )]), + structured_content: None, + is_error: Some(false), + }, + *output_format, + )?; + } + Err(e) => { + eprintln!("Error calling component '{}': {}", component_id, e); + std::process::exit(1); + } + } + } + }, Commands::Registry { command } => match command { RegistryCommands::Search { query, diff --git a/tests/cli_integration_test.rs b/tests/cli_integration_test.rs index ea8e8ed9..1d1d442f 100644 --- a/tests/cli_integration_test.rs +++ b/tests/cli_integration_test.rs @@ -726,7 +726,9 @@ async fn test_cli_inspect_component() -> Result<()> { .expect("Component ID should be in load output"); // Now inspect the loaded component by ID - let (stdout, stderr, exit_code) = ctx.run_command(&["inspect", component_id]).await?; + let (stdout, stderr, exit_code) = ctx + .run_command(&["inspect", "schema", component_id]) + .await?; assert_eq!(exit_code, 0, "Inspect command failed with stderr: {stderr}"); @@ -753,7 +755,7 @@ async fn test_cli_inspect_invalid_component_id() -> Result<()> { // Try to inspect a non-existent component let (_, stderr, exit_code) = ctx - .run_command(&["inspect", "nonexistent-component"]) + .run_command(&["inspect", "schema", "nonexistent-component"]) .await?; assert_ne!(exit_code, 0, "Command should fail for invalid component ID"); @@ -900,3 +902,202 @@ async fn test_cli_autocomplete_includes_all_commands() -> Result<()> { Ok(()) } + +#[test(tokio::test)] +async fn test_cli_inspect_call_component() -> Result<()> { + let ctx = CliTestContext::new().await?; + let component_path = build_fetch_component().await?; + + // First, load the component + let file_uri = format!("file://{}", component_path.display()); + let (load_stdout, load_stderr, load_exit_code) = + ctx.run_command(&["component", "load", &file_uri]).await?; + + assert_eq!( + load_exit_code, 0, + "Load command failed with stderr: {load_stderr}" + ); + + let load_output: Value = ctx.parse_json_output(&load_stdout)?; + let component_id = load_output["id"] + .as_str() + .expect("Component ID should be in load output"); + + // Grant network permission for the component to call example.com + let (_grant_stdout, grant_stderr, grant_exit_code) = ctx + .run_command(&[ + "permission", + "grant", + "network", + component_id, + "example.com", + ]) + .await?; + + assert_eq!( + grant_exit_code, 0, + "Grant network permission failed with stderr: {grant_stderr}" + ); + + // Now call the component with arguments + let (stdout, stderr, exit_code) = ctx + .run_command(&[ + "inspect", + "call", + component_id, + "--arg", + "url=https://example.com", + ]) + .await?; + + assert_eq!( + exit_code, 0, + "Inspect call command failed with stderr: {stderr}" + ); + + // Verify the output contains expected result + let result: Value = ctx.parse_json_output(&stdout)?; + assert!( + result.is_object() || result.is_string(), + "Result should be a valid JSON object or string" + ); + + Ok(()) +} + +#[test(tokio::test)] +async fn test_cli_inspect_call_with_multiple_args() -> Result<()> { + let ctx = CliTestContext::new().await?; + let component_path = build_fetch_component().await?; + + // First, load the component + let file_uri = format!("file://{}", component_path.display()); + let (load_stdout, load_stderr, load_exit_code) = + ctx.run_command(&["component", "load", &file_uri]).await?; + + assert_eq!( + load_exit_code, 0, + "Load command failed with stderr: {load_stderr}" + ); + + let load_output: Value = ctx.parse_json_output(&load_stdout)?; + let component_id = load_output["id"] + .as_str() + .expect("Component ID should be in load output"); + + // Grant network permission + let (_grant_stdout, grant_stderr, grant_exit_code) = ctx + .run_command(&[ + "permission", + "grant", + "network", + component_id, + "httpbin.org", + ]) + .await?; + + assert_eq!( + grant_exit_code, 0, + "Grant network permission failed with stderr: {grant_stderr}" + ); + + // Now call the component with multiple arguments + let (stdout, stderr, exit_code) = ctx + .run_command(&[ + "inspect", + "call", + component_id, + "--arg", + "url=https://httpbin.org/get", + "--arg", + "method=GET", + ]) + .await?; + + assert_eq!( + exit_code, 0, + "Inspect call with multiple args failed with stderr: {stderr}" + ); + + // Verify the output is valid JSON + let _result: Value = ctx.parse_json_output(&stdout)?; + + Ok(()) +} + +#[test(tokio::test)] +async fn test_cli_inspect_call_with_policy_file() -> Result<()> { + let ctx = CliTestContext::new().await?; + let component_path = build_fetch_component().await?; + + // First, load the component + let file_uri = format!("file://{}", component_path.display()); + let (load_stdout, load_stderr, load_exit_code) = + ctx.run_command(&["component", "load", &file_uri]).await?; + + assert_eq!( + load_exit_code, 0, + "Load command failed with stderr: {load_stderr}" + ); + + let load_output: Value = ctx.parse_json_output(&load_stdout)?; + let component_id = load_output["id"] + .as_str() + .expect("Component ID should be in load output"); + + // Create a temporary policy file + let policy_content = r#" +permissions: + network: + - host: "example.com" +"#; + let policy_file = ctx.temp_dir.path().join("test-policy.yaml"); + std::fs::write(&policy_file, policy_content)?; + + // Now call the component with the policy file + let (stdout, stderr, exit_code) = ctx + .run_command(&[ + "inspect", + "call", + component_id, + "--arg", + "url=https://example.com", + "--policy-file", + policy_file.to_str().unwrap(), + ]) + .await?; + + assert_eq!( + exit_code, 0, + "Inspect call with policy file failed with stderr: {stderr}" + ); + + // Verify the output is valid JSON + let _result: Value = ctx.parse_json_output(&stdout)?; + + Ok(()) +} + +#[test(tokio::test)] +async fn test_cli_inspect_call_invalid_component() -> Result<()> { + let ctx = CliTestContext::new().await?; + + // Try to call a non-existent component + let (_, stderr, exit_code) = ctx + .run_command(&[ + "inspect", + "call", + "nonexistent-component", + "--arg", + "test=value", + ]) + .await?; + + assert_ne!(exit_code, 0, "Command should fail for invalid component ID"); + assert!( + stderr.contains("not found") || stderr.contains("Error"), + "Error message should indicate component not found" + ); + + Ok(()) +} From 446cfe3d1b5e376d4a5168e4a44a04deb3d17a76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 14 Nov 2025 06:58:16 +0000 Subject: [PATCH 2/2] Fix policy parsing to use PolicyParser and address code review comments - Use policy::PolicyParser::parse_file() instead of manual YAML parsing - Fix policy format to use 'allow' lists as per standard PolicyDocument structure - Fix environment variable permission type consistency (environment-variable vs environment-variables) - Add better error handling for components with multiple tools - Update test policy file to include required 'version' field and 'allow' structure - Remove misleading comments about "simplified version" Addresses all review comments from copilot-pull-request-reviewer[bot] Co-authored-by: Mossaka <5447827+Mossaka@users.noreply.github.com> --- src/main.rs | 200 +++++++++++++++++++--------------- tests/cli_integration_test.rs | 6 +- 2 files changed, 114 insertions(+), 92 deletions(-) diff --git a/src/main.rs b/src/main.rs index a6172376..07509d9d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -780,113 +780,108 @@ async fn main() -> Result<()> { // Apply policy file if provided if let Some(policy_path) = policy_file { - // Read and apply the policy file - let policy_content = std::fs::read_to_string(policy_path) - .context("Failed to read policy file")?; - - // Parse the policy as YAML and convert to JSON for the grant-* tools - let policy: serde_json::Value = serde_yaml::from_str(&policy_content) - .context("Failed to parse policy file as YAML")?; - - // Apply the policy to the component - // This is a simplified version - the full implementation would need to - // parse the policy structure and call the appropriate grant-* functions tracing::info!("Applying policy from file to component {}", component_id); - // For now, we'll use the existing policy application logic - // The policy should contain permissions like storage, network, etc. - if let Some(permissions) = policy.get("permissions") { - // Apply storage permissions - if let Some(storage) = - permissions.get("storage").and_then(|v| v.as_array()) - { - for s in storage { - let uri = - s.get("uri").and_then(|v| v.as_str()).ok_or_else(|| { - anyhow::anyhow!("Storage permission missing 'uri'") - })?; - let access = s - .get("access") - .and_then(|v| v.as_array()) - .ok_or_else(|| { - anyhow::anyhow!("Storage permission missing 'access'") - })? - .iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect::>(); + // Use PolicyParser from the policy crate to parse the policy file + let policy_doc = policy::PolicyParser::parse_file(policy_path) + .context("Failed to parse policy file")?; + // Apply storage permissions from the allow list + if let Some(storage) = &policy_doc.permissions.storage { + if let Some(allow_list) = &storage.allow { + for perm in allow_list { lifecycle_manager .grant_permission( component_id, "storage", &json!({ - "uri": uri, - "access": access + "uri": perm.uri, + "access": perm.access.iter().map(|a| match a { + policy::AccessType::Read => "read", + policy::AccessType::Write => "write", + }).collect::>() }), ) .await?; } } + } - // Apply network permissions - if let Some(network) = - permissions.get("network").and_then(|v| v.as_array()) - { - for n in network { - let host = n.get("host").and_then(|v| v.as_str()).ok_or_else( - || anyhow::anyhow!("Network permission missing 'host'"), - )?; - - lifecycle_manager - .grant_permission( - component_id, - "network", - &json!({ - "host": host - }), - ) - .await?; + // Apply network permissions from the allow list + if let Some(network) = &policy_doc.permissions.network { + if let Some(allow_list) = &network.allow { + for perm in allow_list { + match perm { + policy::NetworkPermission::Host(host) => { + lifecycle_manager + .grant_permission( + component_id, + "network", + &json!({ + "host": host.host + }), + ) + .await?; + } + policy::NetworkPermission::Cidr(cidr) => { + lifecycle_manager + .grant_permission( + component_id, + "network", + &json!({ + "cidr": cidr.cidr + }), + ) + .await?; + } + } } } + } - // Apply environment variable permissions - if let Some(env_vars) = permissions - .get("environment-variables") - .and_then(|v| v.as_array()) - { - for e in env_vars { - let key = - e.get("key").and_then(|v| v.as_str()).ok_or_else(|| { - anyhow::anyhow!( - "Environment variable permission missing 'key'" - ) - })?; - + // Apply environment variable permissions from the allow list + if let Some(environment) = &policy_doc.permissions.environment { + if let Some(allow_list) = &environment.allow { + for perm in allow_list { lifecycle_manager .grant_permission( component_id, "environment-variable", &json!({ - "key": key + "key": perm.key }), ) .await?; } } + } - // Apply memory permissions - if let Some(memory) = permissions.get("memory") { - if let Some(limit) = memory.get("limit").and_then(|v| v.as_str()) { + // Apply resource limits if specified + if let Some(resources) = &policy_doc.permissions.resources { + if let Some(limits) = &resources.limits { + let mut limits_json = json!({}); + + if let Some(cpu) = &limits.cpu { + limits_json["cpu"] = match cpu { + policy::CpuLimit::String(s) => json!(s), + policy::CpuLimit::Number(n) => json!(n), + }; + } + + if let Some(memory) = &limits.memory { + limits_json["memory"] = match memory { + policy::MemoryLimit::String(s) => json!(s), + policy::MemoryLimit::Number(n) => json!(n), + }; + } + + if !limits_json.as_object().unwrap().is_empty() { lifecycle_manager .grant_permission( component_id, - "memory", + "resources", &json!({ - "resources": { - "limits": { - "memory": limit - } - } + "limits": limits_json }), ) .await?; @@ -904,24 +899,49 @@ async fn main() -> Result<()> { component_id ))?; - // Find the first tool in the component + // Find the tool to call in the component let tool_name = if let Some(arr) = schema["tools"].as_array() { - if let Some(first_tool) = arr.first() { - // Try nested structure first (live component schema) - let tool_info = if first_tool.get("properties").is_some() { - &first_tool["properties"]["result"] - } else { - // Fallback to flat structure (metadata schema) - first_tool - }; - - tool_info["name"] - .as_str() - .ok_or_else(|| anyhow::anyhow!("Tool name not found in schema"))? - .to_string() - } else { + if arr.is_empty() { bail!("No tools found in component"); } + + // Check if component has multiple tools + if arr.len() > 1 { + // Collect tool names for error message + let tool_names: Vec = arr + .iter() + .map(|tool| { + let tool_info = if tool.get("properties").is_some() { + &tool["properties"]["result"] + } else { + tool + }; + tool_info["name"] + .as_str() + .unwrap_or("") + .to_string() + }) + .collect(); + bail!( + "Component '{}' has multiple tools: [{}]. This command currently only supports components with a single tool.", + component_id, + tool_names.join(", ") + ); + } + + // Only one tool present, proceed + let first_tool = &arr[0]; + let tool_info = if first_tool.get("properties").is_some() { + &first_tool["properties"]["result"] + } else { + // Fallback to flat structure (metadata schema) + first_tool + }; + + tool_info["name"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("Tool name not found in schema"))? + .to_string() } else { bail!("No tools found in component"); }; diff --git a/tests/cli_integration_test.rs b/tests/cli_integration_test.rs index 1d1d442f..f5edc6cf 100644 --- a/tests/cli_integration_test.rs +++ b/tests/cli_integration_test.rs @@ -1045,11 +1045,13 @@ async fn test_cli_inspect_call_with_policy_file() -> Result<()> { .as_str() .expect("Component ID should be in load output"); - // Create a temporary policy file + // Create a temporary policy file with correct format let policy_content = r#" +version: "1.0" permissions: network: - - host: "example.com" + allow: + - host: "example.com" "#; let policy_file = ctx.temp_dir.path().join("test-policy.yaml"); std::fs::write(&policy_file, policy_content)?;