From 462de32a5a8445969d7bddec25c4fc3def34067e Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 19 Jun 2026 19:58:10 +0900 Subject: [PATCH] plugin: add cli inspection --- crates/manifest/src/plugin.rs | 2 +- crates/pod/src/feature/plugin.rs | 195 ++++++++ crates/yoi/src/main.rs | 133 ++++- crates/yoi/src/plugin_cli.rs | 832 +++++++++++++++++++++++++++++++ 4 files changed, 1160 insertions(+), 2 deletions(-) create mode 100644 crates/yoi/src/plugin_cli.rs diff --git a/crates/manifest/src/plugin.rs b/crates/manifest/src/plugin.rs index b59a824b..70af3127 100644 --- a/crates/manifest/src/plugin.rs +++ b/crates/manifest/src/plugin.rs @@ -436,7 +436,7 @@ pub struct ResolvedPluginRecord { } impl ResolvedPluginRecord { - fn from_resolved(resolved: &ResolvedPlugin) -> Self { + pub fn from_resolved(resolved: &ResolvedPlugin) -> Self { Self { identity: resolved.identity.clone(), source: resolved.source, diff --git a/crates/pod/src/feature/plugin.rs b/crates/pod/src/feature/plugin.rs index 97949a31..dd2774cf 100644 --- a/crates/pod/src/feature/plugin.rs +++ b/crates/pod/src/feature/plugin.rs @@ -18,6 +18,7 @@ use manifest::plugin::{ PluginConfig, PluginDiscoveryLimits, PluginHostApi, PluginPermission, PluginSurface, PluginToolManifest, ResolvedPluginRecord, read_resolved_plugin_runtime_module, }; +use serde::Serialize; use serde_json::Value; use super::{ @@ -76,6 +77,159 @@ impl PluginToolFeature { } } +/// Static, read-only eligibility information for a resolved plugin package. +/// +/// This inspection mirrors the registration-time permission checks without +/// loading the WASM module, calling a plugin Tool, or executing plugin code. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PluginStaticInspection { + pub runtime: PluginRuntimeEligibility, + pub host_apis: Vec, + pub tools: Vec, +} + +impl PluginStaticInspection { + pub fn statically_eligible(&self) -> bool { + self.runtime.eligible + && self.host_apis.iter().all(|api| api.eligible) + && self.tools.iter().all(|tool| tool.eligible) + } +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PluginRuntimeEligibility { + pub eligible: bool, + pub status: String, + pub diagnostic: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PluginPermissionEligibility { + pub permission: String, + pub requested: bool, + pub granted: bool, + pub eligible: bool, + pub diagnostic: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct PluginToolEligibility { + pub name: String, + pub permission: String, + pub requested: bool, + pub granted: bool, + pub eligible: bool, + pub external_write: bool, + pub diagnostic: Option, +} + +/// Inspect static plugin runtime/tool eligibility without executing plugin code. +pub fn inspect_resolved_plugin_static(record: &ResolvedPluginRecord) -> PluginStaticInspection { + let runtime = match &record.manifest.runtime { + Some(runtime) + if runtime.kind == "wasm" && runtime.abi.as_deref() == Some("yoi-plugin-wasm-1") => + { + PluginRuntimeEligibility { + eligible: true, + status: "wasm/yoi-plugin-wasm-1".to_string(), + diagnostic: None, + } + } + Some(runtime) if runtime.kind == "wasm" => { + let status = runtime + .abi + .as_deref() + .map(|abi| format!("wasm/{abi}")) + .unwrap_or_else(|| "wasm/".to_string()); + PluginRuntimeEligibility { + eligible: false, + status, + diagnostic: Some("unsupported or missing plugin runtime ABI".to_string()), + } + } + Some(runtime) => PluginRuntimeEligibility { + eligible: false, + status: runtime.kind.clone(), + diagnostic: Some(format!( + "unsupported plugin runtime kind `{}`", + runtime.kind + )), + }, + None => PluginRuntimeEligibility { + eligible: false, + status: "none".to_string(), + diagnostic: Some("plugin runtime is not declared".to_string()), + }, + }; + + let host_apis = [PluginHostApi::Https, PluginHostApi::Fs] + .into_iter() + .filter_map(|api| { + let permission = PluginPermission::host_api(api); + let requested = permission_requested(record, &permission); + let granted = grant_allows(record, &permission); + if !requested && !granted { + return None; + } + let diagnostic = authorize_plugin_host_api(record, api) + .err() + .map(|error| error.bounded_message()); + Some(PluginPermissionEligibility { + permission: permission.label(), + requested, + granted, + eligible: diagnostic.is_none(), + diagnostic, + }) + }) + .collect(); + + let tools = record + .manifest + .tools + .iter() + .map(|tool| { + let permission = PluginPermission::tool(&tool.name); + let requested = permission_requested(record, &permission); + let granted = grant_allows(record, &permission); + let diagnostic = authorize_plugin_tool(record, tool) + .err() + .map(|error| error.bounded_message()); + PluginToolEligibility { + name: tool.name.clone(), + permission: permission.label(), + requested, + granted, + eligible: diagnostic.is_none(), + external_write: tool.external_write, + diagnostic, + } + }) + .collect(); + + PluginStaticInspection { + runtime, + host_apis, + tools, + } +} + +fn permission_requested(record: &ResolvedPluginRecord, permission: &PluginPermission) -> bool { + record + .manifest + .permissions + .iter() + .any(|requested| requested == permission) +} + +fn grant_allows(record: &ResolvedPluginRecord, permission: &PluginPermission) -> bool { + record + .grants + .permissions + .iter() + .any(|granted| granted == permission) +} + impl FeatureModule for PluginToolFeature { fn descriptor(&self) -> FeatureDescriptor { let mut descriptor = @@ -1665,6 +1819,47 @@ input_schema = { type = "object", additionalProperties = true } .collect() } + #[test] + fn static_inspection_does_not_read_or_execute_package() { + let mut record = record(vec![tool("Echo")]); + record.package_path = std::path::PathBuf::from("/no/such/plugin.wasm"); + record.manifest.runtime = Some(PluginRuntimeManifest { + kind: "wasm".to_string(), + entry: "plugin.wasm".to_string(), + abi: Some("yoi-plugin-wasm-1".to_string()), + }); + + let inspection = inspect_resolved_plugin_static(&record); + + assert!(inspection.runtime.eligible); + assert_eq!(inspection.tools.len(), 1); + assert!(inspection.tools[0].eligible); + assert!(inspection.statically_eligible()); + } + + #[test] + fn static_inspection_reports_missing_tool_grant() { + let mut record = record(vec![tool("Echo")]); + record.manifest.runtime = Some(PluginRuntimeManifest { + kind: "wasm".to_string(), + entry: "plugin.wasm".to_string(), + abi: Some("yoi-plugin-wasm-1".to_string()), + }); + record.grants.permissions = vec![PluginPermission::surface(PluginSurface::Tool)]; + + let inspection = inspect_resolved_plugin_static(&record); + + assert!(!inspection.statically_eligible()); + assert!(!inspection.tools[0].eligible); + assert!( + inspection.tools[0] + .diagnostic + .as_deref() + .unwrap_or_default() + .contains("grant") + ); + } + fn write_stored_zip(path: &Path, files: &[(&str, &[u8])]) { let mut out = Vec::new(); let mut central = Vec::new(); diff --git a/crates/yoi/src/main.rs b/crates/yoi/src/main.rs index 62483409..e6c5b400 100644 --- a/crates/yoi/src/main.rs +++ b/crates/yoi/src/main.rs @@ -1,5 +1,6 @@ mod memory_lint; mod objective_cli; +mod plugin_cli; mod session_cli; mod ticket_cli; @@ -17,6 +18,7 @@ enum Mode { Help, MemoryLintHelp, MemoryLint(LintCliOptions), + Plugin(plugin_cli::PluginCliCommand), Objective(objective_cli::ObjectiveCli), Session(session_cli::SessionCli), Ticket(ticket_cli::TicketCli), @@ -68,6 +70,13 @@ async fn main() -> ExitCode { ExitCode::FAILURE } }, + Mode::Plugin(command) => match plugin_cli::run(command) { + Ok(()) => ExitCode::SUCCESS, + Err(e) => { + eprintln!("yoi plugin: {e}"); + ExitCode::FAILURE + } + }, Mode::Objective(cli) => match objective_cli::run(cli) { Ok(output) => { print!("{}", output.stdout); @@ -173,6 +182,10 @@ fn parse_args_slice(args: &[String]) -> Result { ticket_cli::parse_ticket_args(&args[1..]).map_err(|e| ParseError(e.to_string()))?; return Ok(Mode::Ticket(ticket_cli)); } + "plugin" => { + let plugin_cli = parse_plugin_args(&args[1..])?; + return Ok(Mode::Plugin(plugin_cli)); + } "panel" => { return Ok(Mode::Tui { mode: LaunchMode::Panel, @@ -413,6 +426,97 @@ fn parse_args_slice(args: &[String]) -> Result { }) } +fn parse_plugin_args(args: &[String]) -> Result { + let Some((subcommand, rest)) = args.split_first() else { + return Err(ParseError( + "yoi plugin requires `list` or `show `".to_string(), + )); + }; + match subcommand.as_str() { + "list" => { + let (plugin_args, positional) = parse_plugin_common_args(rest)?; + if !positional.is_empty() { + return Err(ParseError( + "yoi plugin list does not accept positional arguments".to_string(), + )); + } + Ok(plugin_cli::PluginCliCommand::List(plugin_args)) + } + "show" => { + let (plugin_args, positional) = parse_plugin_common_args(rest)?; + match positional.as_slice() { + [reference] => Ok(plugin_cli::PluginCliCommand::Show { + reference: reference.clone(), + args: plugin_args, + }), + [] => Err(ParseError( + "yoi plugin show requires a plugin ref".to_string(), + )), + _ => Err(ParseError( + "yoi plugin show accepts exactly one plugin ref".to_string(), + )), + } + } + "--help" | "-h" => Err(ParseError(plugin_usage().to_string())), + other => Err(ParseError(format!( + "unknown yoi plugin subcommand `{other}`" + ))), + } +} + +fn parse_plugin_common_args( + args: &[String], +) -> Result<(plugin_cli::PluginCliArgs, Vec), ParseError> { + let mut parsed = plugin_cli::PluginCliArgs::default(); + let mut positional = Vec::new(); + let mut index = 0; + while index < args.len() { + let arg = &args[index]; + match arg.as_str() { + "--json" => parsed.json = true, + "--workspace" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(ParseError("--workspace requires a value".to_string())); + }; + parsed.workspace = Some(PathBuf::from(value)); + } + "--profile" => { + index += 1; + let Some(value) = args.get(index) else { + return Err(ParseError("--profile requires a value".to_string())); + }; + parsed.profile = Some(value.clone()); + } + "--help" | "-h" => return Err(ParseError(plugin_usage().to_string())), + _ if arg.starts_with("--workspace=") => { + let value = arg.trim_start_matches("--workspace="); + if value.is_empty() { + return Err(ParseError("--workspace requires a value".to_string())); + } + parsed.workspace = Some(PathBuf::from(value)); + } + _ if arg.starts_with("--profile=") => { + let value = arg.trim_start_matches("--profile="); + if value.is_empty() { + return Err(ParseError("--profile requires a value".to_string())); + } + parsed.profile = Some(value.to_string()); + } + _ if arg.starts_with('-') => { + return Err(ParseError(format!("unknown yoi plugin option `{arg}`"))); + } + _ => positional.push(arg.clone()), + } + index += 1; + } + Ok((parsed, positional)) +} + +fn plugin_usage() -> &'static str { + "usage: yoi plugin list [--workspace PATH] [--profile REF] [--json]\n yoi plugin show [--workspace PATH] [--profile REF] [--json]" +} + fn parse_panel_workspace(args: &[String]) -> Result { match args { [] => std::env::current_dir() @@ -443,7 +547,7 @@ fn parse_session_id(value: &str) -> Result { fn print_help() { println!( - "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" + "yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace ]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective [OPTIONS]\n yoi session analyze --json\n yoi ticket [OPTIONS]\n yoi plugin list [--workspace ] [--profile ] [--json]\n yoi plugin show [--workspace ] [--profile ] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace Runtime workspace root (defaults to cwd)\n --pod Attach/restore/create a Pod by name\n --socket Attach to a specific Pod socket with --pod\n --session Resume a specific session segment\n --profile Select a reusable Profile recipe\n -h, --help Print help\n" ); } @@ -607,6 +711,33 @@ mod tests { } } + #[test] + fn parse_plugin_list_and_show() { + match parse_args_from(["plugin", "list", "--workspace=/tmp/ws", "--json"]).unwrap() { + Mode::Plugin(plugin_cli::PluginCliCommand::List(options)) => { + assert_eq!(options.workspace, Some(PathBuf::from("/tmp/ws"))); + assert!(options.json); + } + _ => panic!("expected Plugin list mode"), + } + + match parse_args_from([ + "plugin", + "show", + "project:echo", + "--profile", + "project:inspect", + ]) + .unwrap() + { + Mode::Plugin(plugin_cli::PluginCliCommand::Show { reference, args }) => { + assert_eq!(reference, "project:echo"); + assert_eq!(args.profile.as_deref(), Some("project:inspect")); + } + _ => panic!("expected Plugin show mode"), + } + } + #[test] fn parse_memory_lint_rejects_usage_errors() { let err = parse_args_from(["memory", "lint", "--workspace"]).unwrap_err(); diff --git a/crates/yoi/src/plugin_cli.rs b/crates/yoi/src/plugin_cli.rs new file mode 100644 index 00000000..201a5f01 --- /dev/null +++ b/crates/yoi/src/plugin_cli.rs @@ -0,0 +1,832 @@ +use std::collections::BTreeMap; +use std::error::Error; +use std::fmt::Write as _; +use std::fs; +use std::path::{Path, PathBuf}; + +use manifest::plugin::{ + PluginConfig, PluginDiagnostic, PluginDiagnosticKind, PluginDiscoveryLimits, + PluginDiscoveryOptions, PluginDiscoveryReport, PluginPackageManifest, PluginPermission, + PluginResolution, PluginSurface, ResolvedPlugin, ResolvedPluginRecord, SourceQualifiedPluginId, + discover_plugins, resolve_enabled_plugins, +}; +use manifest::{ProfileResolveOptions, ProfileResolver, ProfileSelector, paths}; +use pod::feature::plugin::{PluginStaticInspection, inspect_resolved_plugin_static}; +use serde::Serialize; + +type Result = std::result::Result>; + +const MAX_LIST_ITEMS: usize = 128; +const MAX_SHOW_DIAGNOSTICS: usize = 48; +const MAX_LIST_DIAGNOSTICS_PER_ITEM: usize = 3; +const MAX_TEXT: usize = 240; + +#[derive(Clone, Debug, Default)] +pub(crate) struct PluginCliArgs { + pub workspace: Option, + pub profile: Option, + pub json: bool, +} + +#[derive(Clone, Debug)] +pub(crate) enum PluginCliCommand { + List(PluginCliArgs), + Show { + reference: String, + args: PluginCliArgs, + }, +} + +pub(crate) fn run(command: PluginCliCommand) -> Result<()> { + let rendered = match command { + PluginCliCommand::List(args) => render_list(&args)?, + PluginCliCommand::Show { reference, args } => render_show(&reference, &args)?, + }; + print!("{rendered}"); + Ok(()) +} + +fn render_list(args: &PluginCliArgs) -> Result { + let snapshot = build_snapshot(args)?; + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(&snapshot)?)); + } + + let mut out = String::new(); + writeln!( + out, + "plugin packages (workspace: {})", + snapshot.workspace.display() + )?; + if snapshot.items.is_empty() { + writeln!(out, " (none)")?; + return Ok(out); + } + + for item in snapshot.items.iter().take(MAX_LIST_ITEMS) { + writeln!( + out, + "- {} [{}] version={} digest={} source={} tools={} diagnostics={}", + item.reference, + item.status, + item.version.as_deref().unwrap_or(""), + item.digest.as_deref().unwrap_or(""), + item.source.as_deref().unwrap_or(""), + item.tools.len(), + item.diagnostics.len() + )?; + for diagnostic in item.diagnostics.iter().take(MAX_LIST_DIAGNOSTICS_PER_ITEM) { + writeln!(out, " diagnostic: {}", diagnostic.message)?; + } + } + if snapshot.items.len() > MAX_LIST_ITEMS { + writeln!( + out, + " ... {} more plugin package(s) omitted", + snapshot.items.len() - MAX_LIST_ITEMS + )?; + } + Ok(out) +} + +fn render_show(reference: &str, args: &PluginCliArgs) -> Result { + let snapshot = build_snapshot(args)?; + let item = select_item(&snapshot, reference)?; + if args.json { + return Ok(format!("{}\n", serde_json::to_string_pretty(item)?)); + } + + let mut out = String::new(); + writeln!(out, "plugin {}", item.reference)?; + writeln!(out, " status: {}", item.status)?; + writeln!( + out, + " source: {}", + item.source.as_deref().unwrap_or("") + )?; + writeln!( + out, + " package: {}", + item.package.as_deref().unwrap_or("") + )?; + writeln!( + out, + " version: {}", + item.version.as_deref().unwrap_or("") + )?; + writeln!( + out, + " digest: {}", + item.digest.as_deref().unwrap_or("") + )?; + writeln!(out, " configured: {}", item.configured)?; + writeln!(out, " discovered: {}", item.discovered)?; + writeln!(out, " resolved: {}", item.resolved)?; + writeln!(out, " static_eligible: {}", item.static_eligible)?; + + writeln!( + out, + " declared_surfaces: {}", + join_or_none(&item.declared_surfaces) + )?; + writeln!( + out, + " enabled_surfaces: {}", + join_or_none(&item.enabled_surfaces) + )?; + writeln!( + out, + " requested_permissions: {}", + join_or_none(&item.requested_permissions) + )?; + writeln!( + out, + " configured_grants: {}", + join_or_none(&item.configured_grants) + )?; + + if let Some(runtime) = &item.static_runtime { + writeln!( + out, + " runtime: {} eligible={}", + runtime.runtime.status, runtime.runtime.eligible + )?; + if let Some(diagnostic) = &runtime.runtime.diagnostic { + writeln!(out, " diagnostic: {diagnostic}")?; + } + if !runtime.host_apis.is_empty() { + writeln!(out, " host_api_permissions:")?; + for api in &runtime.host_apis { + writeln!( + out, + " - {} requested={} granted={} eligible={}", + api.permission, api.requested, api.granted, api.eligible + )?; + if let Some(diagnostic) = &api.diagnostic { + writeln!(out, " diagnostic: {diagnostic}")?; + } + } + } + } + + if item.tools.is_empty() { + writeln!(out, " tools: (none)")?; + } else { + writeln!(out, " tools:")?; + for tool in &item.tools { + writeln!( + out, + " - {} permission={} requested={} granted={} eligible={} external_write={}", + tool.name, + tool.permission, + tool.requested, + tool.granted, + tool.eligible, + tool.external_write + )?; + if let Some(diagnostic) = &tool.diagnostic { + writeln!(out, " diagnostic: {diagnostic}")?; + } + } + } + + if item.diagnostics.is_empty() { + writeln!(out, " diagnostics: (none)")?; + } else { + writeln!(out, " diagnostics:")?; + for diagnostic in item.diagnostics.iter().take(MAX_SHOW_DIAGNOSTICS) { + writeln!(out, " - [{}] {}", diagnostic.kind, diagnostic.message)?; + } + if item.diagnostics.len() > MAX_SHOW_DIAGNOSTICS { + writeln!( + out, + " ... {} more diagnostic(s) omitted", + item.diagnostics.len() - MAX_SHOW_DIAGNOSTICS + )?; + } + } + + Ok(out) +} + +fn join_or_none(values: &[String]) -> String { + if values.is_empty() { + "(none)".to_string() + } else { + values.join(", ") + } +} + +fn build_snapshot(args: &PluginCliArgs) -> Result { + let workspace = args + .workspace + .clone() + .map(Ok) + .unwrap_or_else(std::env::current_dir)?; + let workspace = fs::canonicalize(&workspace).map_err(|error| { + format!( + "workspace `{}` does not exist: {error}", + workspace.display() + ) + })?; + let config = load_plugin_config(args, &workspace)?; + + let options = PluginDiscoveryOptions { + workspace_root: workspace.clone(), + user_data_home: paths::data_dir(), + limits: PluginDiscoveryLimits::default(), + }; + let discovery = discover_plugins(&options); + let resolution = resolve_enabled_plugins(&config, &discovery); + + Ok(snapshot_from_resolution( + workspace, + &config, + &discovery, + &resolution, + )) +} + +fn load_plugin_config(args: &PluginCliArgs, workspace: &Path) -> Result { + let selector = args + .profile + .as_deref() + .map(ProfileSelector::parse_cli) + .unwrap_or(ProfileSelector::Default); + let resolved = ProfileResolver::new() + .with_workspace_base(workspace) + .resolve( + &selector, + ProfileResolveOptions::with_pod_name("plugin-inspect"), + )?; + Ok(resolved.manifest.plugins) +} + +fn snapshot_from_resolution( + workspace: PathBuf, + config: &PluginConfig, + discovery: &PluginDiscoveryReport, + resolution: &PluginResolution, +) -> PluginInspectionSnapshot { + let mut builders: BTreeMap = BTreeMap::new(); + + for package in &discovery.packages { + let key = package.identity.to_string(); + let builder = builders + .entry(key.clone()) + .or_insert_with(|| ItemBuilder::new(key)); + builder.discovered = true; + builder.source = Some(package.identity.source.to_string()); + builder.package = Some(package.package_label.clone()); + builder.digest = Some(package.digest.clone()); + builder.version = Some(package.manifest.version.clone()); + builder.declared_surfaces = surface_strings(package.manifest.surfaces.iter().copied()); + builder.requested_permissions = permission_strings(&package.manifest.permissions); + builder.tools = package + .manifest + .tools + .iter() + .map(|tool| ToolSummary { + name: tool.name.clone(), + permission: PluginPermission::tool(&tool.name).label(), + requested: permission_requested( + &package.manifest, + &PluginPermission::tool(&tool.name), + ), + granted: false, + eligible: false, + external_write: tool.external_write, + diagnostic: Some("plugin is discovered but not enabled".to_string()), + }) + .collect(); + } + + for enablement in &config.enabled { + let key = enablement.id.clone(); + let builder = builders + .entry(key.clone()) + .or_insert_with(|| ItemBuilder::new(key)); + builder.configured = true; + builder.enabled_surfaces = surface_strings(enablement.surfaces.iter().copied()); + builder.configured_grants = permission_strings(&enablement.grants.permissions); + if let Ok(identity) = SourceQualifiedPluginId::parse(&enablement.id) { + builder + .source + .get_or_insert_with(|| identity.source.to_string()); + } + } + + for resolved in &resolution.resolved { + let key = resolved.identity.to_string(); + let builder = builders + .entry(key.clone()) + .or_insert_with(|| ItemBuilder::new(key)); + fill_resolved(builder, resolved); + } + + for diagnostic in discovery + .diagnostics + .iter() + .chain(resolution.diagnostics.iter()) + { + let rendered = DiagnosticSummary::from(diagnostic); + if let Some(reference) = diagnostic_reference(diagnostic) { + builders + .entry(reference.clone()) + .or_insert_with(|| ItemBuilder::new(reference)) + .diagnostics + .push(rendered); + } else { + let key = "".to_string(); + builders + .entry(key.clone()) + .or_insert_with(|| ItemBuilder::new(key)) + .diagnostics + .push(rendered); + } + } + + let items = builders + .into_values() + .map(ItemBuilder::finish) + .take(MAX_LIST_ITEMS + 1) + .collect(); + + PluginInspectionSnapshot { workspace, items } +} + +fn fill_resolved(builder: &mut ItemBuilder, resolved: &ResolvedPlugin) { + builder.configured = true; + builder.discovered = true; + builder.resolved = true; + builder.source = Some(resolved.identity.source.to_string()); + builder.package = Some(resolved.package_label.clone()); + builder.digest = Some(resolved.digest.clone()); + builder.version = Some(resolved.manifest.version.clone()); + builder.declared_surfaces = surface_strings(resolved.manifest.surfaces.iter().copied()); + builder.enabled_surfaces = surface_strings(resolved.enabled_surfaces.iter().copied()); + builder.requested_permissions = permission_strings(&resolved.manifest.permissions); + builder.configured_grants = permission_strings(&resolved.grants.permissions); + + let record = ResolvedPluginRecord::from_resolved(resolved); + let static_runtime = inspect_resolved_plugin_static(&record); + for diagnostic in static_runtime + .runtime + .diagnostic + .iter() + .chain( + static_runtime + .host_apis + .iter() + .filter_map(|api| api.diagnostic.as_ref()), + ) + .chain( + static_runtime + .tools + .iter() + .filter_map(|tool| tool.diagnostic.as_ref()), + ) + { + builder.diagnostics.push(DiagnosticSummary { + kind: "static_eligibility".to_string(), + message: bound_text(diagnostic.clone()), + }); + } + builder.static_eligible = static_runtime.statically_eligible(); + builder.tools = static_runtime + .tools + .iter() + .map(|tool| ToolSummary { + name: tool.name.clone(), + permission: tool.permission.clone(), + requested: tool.requested, + granted: tool.granted, + eligible: tool.eligible, + external_write: tool.external_write, + diagnostic: tool.diagnostic.clone().map(bound_text), + }) + .collect(); + builder.static_runtime = Some(static_runtime); +} + +fn diagnostic_reference(diagnostic: &PluginDiagnostic) -> Option { + diagnostic.identity.clone() +} + +fn select_item<'a>( + snapshot: &'a PluginInspectionSnapshot, + reference: &str, +) -> Result<&'a PluginInspectionItem> { + if let Some(item) = snapshot + .items + .iter() + .find(|item| item.reference == reference) + { + return Ok(item); + } + + let local_matches: Vec<_> = snapshot + .items + .iter() + .filter(|item| item.local_ref.as_deref() == Some(reference)) + .collect(); + match local_matches.as_slice() { + [item] => Ok(item), + [] => Err(format!( + "plugin package `{}` was not found", + bound_text(reference.to_string()) + ) + .into()), + _ => Err(format!( + "plugin package reference `{}` is ambiguous; use a source-qualified ref", + bound_text(reference.to_string()) + ) + .into()), + } +} + +fn surface_strings(surfaces: impl Iterator) -> Vec { + let mut values: Vec<_> = surfaces.map(|surface| surface.to_string()).collect(); + values.sort(); + values.dedup(); + values +} + +fn permission_strings(permissions: &[PluginPermission]) -> Vec { + let mut values: Vec<_> = permissions + .iter() + .map(|permission| permission.label()) + .collect(); + values.sort(); + values.dedup(); + values +} + +fn permission_requested(manifest: &PluginPackageManifest, permission: &PluginPermission) -> bool { + manifest + .permissions + .iter() + .any(|requested| requested == permission) +} + +fn local_ref(reference: &str) -> Option { + SourceQualifiedPluginId::parse(reference) + .ok() + .map(|identity| identity.local_id.to_string()) +} + +fn bound_text(mut text: String) -> String { + text = text.replace('\n', " "); + if text.chars().count() > MAX_TEXT { + text = text.chars().take(MAX_TEXT).collect::(); + text.push('…'); + } + text +} + +#[derive(Clone, Debug, Serialize)] +struct PluginInspectionSnapshot { + workspace: PathBuf, + items: Vec, +} + +#[derive(Clone, Debug, Serialize)] +struct PluginInspectionItem { + reference: String, + local_ref: Option, + status: String, + source: Option, + package: Option, + version: Option, + digest: Option, + configured: bool, + discovered: bool, + resolved: bool, + static_eligible: bool, + declared_surfaces: Vec, + enabled_surfaces: Vec, + requested_permissions: Vec, + configured_grants: Vec, + tools: Vec, + static_runtime: Option, + diagnostics: Vec, +} + +#[derive(Clone, Debug, Serialize)] +struct ToolSummary { + name: String, + permission: String, + requested: bool, + granted: bool, + eligible: bool, + external_write: bool, + diagnostic: Option, +} + +#[derive(Clone, Debug, Serialize)] +struct DiagnosticSummary { + kind: String, + message: String, +} + +impl DiagnosticSummary { + fn from(diagnostic: &PluginDiagnostic) -> Self { + Self { + kind: diagnostic_kind(&diagnostic.kind).to_string(), + message: bound_text(diagnostic.message.clone()), + } + } +} + +fn diagnostic_kind(kind: &PluginDiagnosticKind) -> &'static str { + match kind { + PluginDiagnosticKind::Missing => "missing", + PluginDiagnosticKind::Duplicate => "duplicate", + PluginDiagnosticKind::Ambiguous => "ambiguous", + PluginDiagnosticKind::Version => "version", + PluginDiagnosticKind::Digest => "digest", + PluginDiagnosticKind::Api => "api", + PluginDiagnosticKind::Surface => "surface", + PluginDiagnosticKind::Grant => "grant", + PluginDiagnosticKind::Malformed => "malformed", + PluginDiagnosticKind::Traversal => "traversal", + PluginDiagnosticKind::Bounds => "bounds", + PluginDiagnosticKind::Io => "io", + } +} + +#[derive(Clone, Debug)] +struct ItemBuilder { + reference: String, + configured: bool, + discovered: bool, + resolved: bool, + source: Option, + package: Option, + version: Option, + digest: Option, + static_eligible: bool, + declared_surfaces: Vec, + enabled_surfaces: Vec, + requested_permissions: Vec, + configured_grants: Vec, + tools: Vec, + static_runtime: Option, + diagnostics: Vec, +} + +impl ItemBuilder { + fn new(reference: String) -> Self { + Self { + reference, + configured: false, + discovered: false, + resolved: false, + source: None, + package: None, + version: None, + digest: None, + static_eligible: false, + declared_surfaces: Vec::new(), + enabled_surfaces: Vec::new(), + requested_permissions: Vec::new(), + configured_grants: Vec::new(), + tools: Vec::new(), + static_runtime: None, + diagnostics: Vec::new(), + } + } + + fn finish(mut self) -> PluginInspectionItem { + self.diagnostics.sort_by(|left, right| { + (left.kind.as_str(), left.message.as_str()) + .cmp(&(right.kind.as_str(), right.message.as_str())) + }); + let status = if self.resolved && self.diagnostics.is_empty() { + "enabled".to_string() + } else if self.resolved { + "enabled-with-diagnostics".to_string() + } else if self.configured && self.discovered { + "configured-blocked".to_string() + } else if self.configured { + "configured-missing".to_string() + } else if self.discovered { + "discovered".to_string() + } else { + "diagnostic".to_string() + }; + let local_ref = local_ref(&self.reference); + PluginInspectionItem { + reference: self.reference, + local_ref, + status, + source: self.source, + package: self.package, + version: self.version, + digest: self.digest, + configured: self.configured, + discovered: self.discovered, + resolved: self.resolved, + static_eligible: self.static_eligible, + declared_surfaces: self.declared_surfaces, + enabled_surfaces: self.enabled_surfaces, + requested_permissions: self.requested_permissions, + configured_grants: self.configured_grants, + tools: self.tools, + static_runtime: self.static_runtime, + diagnostics: self.diagnostics, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use manifest::plugin::{PluginEnablementConfig, PluginExactVersion, PluginGrantConfig}; + use tempfile::tempdir; + + #[test] + fn list_and_show_resolved_plugin_without_execution() { + let dir = tempdir().unwrap(); + let workspace = dir.path(); + write_plugin_package(workspace, "echo"); + let config = enabled_config(workspace, true, None); + + let discovery = discover_plugins(&PluginDiscoveryOptions { + workspace_root: workspace.to_path_buf(), + user_data_home: None, + limits: PluginDiscoveryLimits::default(), + }); + let resolution = resolve_enabled_plugins(&config, &discovery); + let snapshot = + snapshot_from_resolution(workspace.to_path_buf(), &config, &discovery, &resolution); + + assert_eq!(snapshot.items.len(), 1); + let item = select_item(&snapshot, "echo").unwrap(); + assert_eq!(item.status, "enabled"); + assert_eq!(item.tools[0].name, "Echo"); + assert!(item.static_eligible); + assert_eq!(item.package.as_deref(), Some("echo.yoi-plugin")); + } + + #[test] + fn missing_ref_is_bounded_error() { + let snapshot = PluginInspectionSnapshot { + workspace: PathBuf::from("/tmp/workspace"), + items: Vec::new(), + }; + let error = select_item(&snapshot, "missing").unwrap_err().to_string(); + assert!(error.contains("missing")); + assert!(error.len() < 120); + } + + #[test] + fn grant_mismatch_is_inspectable() { + let dir = tempdir().unwrap(); + let workspace = dir.path(); + let digest = write_plugin_package(workspace, "echo"); + let mut config = enabled_config(workspace, false, Some(digest)); + config.enabled[0].grants.permissions = vec![PluginPermission::tool("Other")]; + + let discovery = discover_plugins(&PluginDiscoveryOptions { + workspace_root: workspace.to_path_buf(), + user_data_home: None, + limits: PluginDiscoveryLimits::default(), + }); + let resolution = resolve_enabled_plugins(&config, &discovery); + let snapshot = + snapshot_from_resolution(workspace.to_path_buf(), &config, &discovery, &resolution); + let item = select_item(&snapshot, "project:echo").unwrap(); + assert_eq!(item.status, "enabled-with-diagnostics"); + assert!(!item.static_eligible); + assert!( + item.diagnostics + .iter() + .any(|diagnostic| diagnostic.kind == "static_eligibility") + ); + } + + fn enabled_config( + workspace: &Path, + include_digest: bool, + digest_override: Option, + ) -> PluginConfig { + let digest = digest_override.unwrap_or_else(|| write_plugin_package(workspace, "echo")); + PluginConfig { + enabled: vec![PluginEnablementConfig { + id: "project:echo".to_string(), + digest: include_digest.then_some(digest.clone()), + version: Some(PluginExactVersion("0.1.0".to_string())), + surfaces: vec![PluginSurface::Tool], + grants: PluginGrantConfig { + id: Some("project:echo".to_string()), + version: Some(PluginExactVersion("0.1.0".to_string())), + digest: Some(digest), + permissions: vec![ + PluginPermission::surface(PluginSurface::Tool), + PluginPermission::tool("Echo"), + ], + }, + config: None, + }], + ..PluginConfig::default() + } + } + + fn write_plugin_package(workspace: &Path, id: &str) -> String { + let package_dir = workspace.join(".yoi/plugins"); + fs::create_dir_all(&package_dir).unwrap(); + let package = package_dir.join(format!("{id}.yoi-plugin")); + let manifest = r#" +schema_version = 1 +id = "echo" +name = "Echo" +version = "0.1.0" +surfaces = ["tool"] +permissions = [{ kind = "surface", surface = "tool" }, { kind = "tool", name = "Echo" }] + +[runtime] +kind = "wasm" +entry = "plugin.wasm" +abi = "yoi-plugin-wasm-1" + +[[tools]] +name = "Echo" +description = "Echo input" +input_schema = { type = "object" } +"#; + write_stored_zip( + &package, + &[ + ("plugin.toml", manifest.as_bytes()), + ("plugin.wasm", b"not wasm"), + ], + ); + + let discovery = discover_plugins(&PluginDiscoveryOptions { + workspace_root: workspace.to_path_buf(), + user_data_home: None, + limits: PluginDiscoveryLimits::default(), + }); + discovery.packages[0].digest.clone() + } + + fn write_stored_zip(path: &Path, entries: &[(&str, &[u8])]) { + let mut bytes = Vec::new(); + let mut central = Vec::new(); + for (name, content) in entries { + let local_offset = bytes.len() as u32; + write_u32(&mut bytes, 0x0403_4b50); + write_u16(&mut bytes, 20); + write_u16(&mut bytes, 0x0800); + write_u16(&mut bytes, 0); + write_u16(&mut bytes, 0); + write_u16(&mut bytes, 0); + write_u32(&mut bytes, 0); + write_u32(&mut bytes, content.len() as u32); + write_u32(&mut bytes, content.len() as u32); + write_u16(&mut bytes, name.len() as u16); + write_u16(&mut bytes, 0); + bytes.extend_from_slice(name.as_bytes()); + bytes.extend_from_slice(content); + + write_u32(&mut central, 0x0201_4b50); + write_u16(&mut central, 20); + write_u16(&mut central, 20); + write_u16(&mut central, 0x0800); + write_u16(&mut central, 0); + write_u16(&mut central, 0); + write_u16(&mut central, 0); + write_u32(&mut central, 0); + write_u32(&mut central, content.len() as u32); + write_u32(&mut central, content.len() as u32); + write_u16(&mut central, name.len() as u16); + write_u16(&mut central, 0); + write_u16(&mut central, 0); + write_u16(&mut central, 0); + write_u16(&mut central, 0); + write_u32(&mut central, 0); + write_u32(&mut central, local_offset); + central.extend_from_slice(name.as_bytes()); + } + let central_offset = bytes.len() as u32; + bytes.extend_from_slice(¢ral); + write_u32(&mut bytes, 0x0605_4b50); + write_u16(&mut bytes, 0); + write_u16(&mut bytes, 0); + write_u16(&mut bytes, entries.len() as u16); + write_u16(&mut bytes, entries.len() as u16); + write_u32(&mut bytes, central.len() as u32); + write_u32(&mut bytes, central_offset); + write_u16(&mut bytes, 0); + fs::write(path, bytes).unwrap(); + } + + fn write_u16(bytes: &mut Vec, value: u16) { + bytes.extend_from_slice(&value.to_le_bytes()); + } + + fn write_u32(bytes: &mut Vec, value: u32) { + bytes.extend_from_slice(&value.to_le_bytes()); + } +}