plugin: add cli inspection
This commit is contained in:
parent
d51b610f97
commit
462de32a5a
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<PluginPermissionEligibility>,
|
||||
pub tools: Vec<PluginToolEligibility>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
|
||||
pub struct PluginPermissionEligibility {
|
||||
pub permission: String,
|
||||
pub requested: bool,
|
||||
pub granted: bool,
|
||||
pub eligible: bool,
|
||||
pub diagnostic: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
}
|
||||
|
||||
/// 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/<missing-abi>".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();
|
||||
|
|
|
|||
|
|
@ -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<Mode, ParseError> {
|
|||
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<Mode, ParseError> {
|
|||
})
|
||||
}
|
||||
|
||||
fn parse_plugin_args(args: &[String]) -> Result<plugin_cli::PluginCliCommand, ParseError> {
|
||||
let Some((subcommand, rest)) = args.split_first() else {
|
||||
return Err(ParseError(
|
||||
"yoi plugin requires `list` or `show <ref>`".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<String>), 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 <ref> [--workspace PATH] [--profile REF] [--json]"
|
||||
}
|
||||
|
||||
fn parse_panel_workspace(args: &[String]) -> Result<PathBuf, ParseError> {
|
||||
match args {
|
||||
[] => std::env::current_dir()
|
||||
|
|
@ -443,7 +547,7 @@ fn parse_session_id(value: &str) -> Result<SegmentId, ParseError> {
|
|||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> Select a reusable Profile recipe\n -h, --help Print help\n"
|
||||
"yoi\n\nUsage:\n yoi [OPTIONS] [POD_NAME]\n yoi panel [--workspace <PATH>]\n yoi keys\n yoi setup-model\n yoi pod [POD_OPTIONS]\n yoi objective <COMMAND> [OPTIONS]\n yoi session analyze <SESSION_JSONL_PATH> --json\n yoi ticket <COMMAND> [OPTIONS]\n yoi plugin list [--workspace <PATH>] [--profile <REF>] [--json]\n yoi plugin show <REF> [--workspace <PATH>] [--profile <REF>] [--json]\n yoi memory lint [OPTIONS]\n\nOptions:\n -r, --resume Open the Pod picker and resume/attach a Pod\n --workspace <PATH> Runtime workspace root (defaults to cwd)\n --pod <NAME> Attach/restore/create a Pod by name\n --socket <PATH> Attach to a specific Pod socket with --pod\n --session <UUID> Resume a specific session segment\n --profile <REF> 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();
|
||||
|
|
|
|||
832
crates/yoi/src/plugin_cli.rs
Normal file
832
crates/yoi/src/plugin_cli.rs
Normal file
|
|
@ -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<T> = std::result::Result<T, Box<dyn Error>>;
|
||||
|
||||
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<PathBuf>,
|
||||
pub profile: Option<String>,
|
||||
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<String> {
|
||||
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("<unknown>"),
|
||||
item.digest.as_deref().unwrap_or("<unknown>"),
|
||||
item.source.as_deref().unwrap_or("<unknown>"),
|
||||
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<String> {
|
||||
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("<unknown>")
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" package: {}",
|
||||
item.package.as_deref().unwrap_or("<unknown>")
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" version: {}",
|
||||
item.version.as_deref().unwrap_or("<unknown>")
|
||||
)?;
|
||||
writeln!(
|
||||
out,
|
||||
" digest: {}",
|
||||
item.digest.as_deref().unwrap_or("<unknown>")
|
||||
)?;
|
||||
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<PluginInspectionSnapshot> {
|
||||
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<PluginConfig> {
|
||||
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<String, ItemBuilder> = 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 = "<global>".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<String> {
|
||||
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<Item = PluginSurface>) -> Vec<String> {
|
||||
let mut values: Vec<_> = surfaces.map(|surface| surface.to_string()).collect();
|
||||
values.sort();
|
||||
values.dedup();
|
||||
values
|
||||
}
|
||||
|
||||
fn permission_strings(permissions: &[PluginPermission]) -> Vec<String> {
|
||||
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<String> {
|
||||
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::<String>();
|
||||
text.push('…');
|
||||
}
|
||||
text
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct PluginInspectionSnapshot {
|
||||
workspace: PathBuf,
|
||||
items: Vec<PluginInspectionItem>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct PluginInspectionItem {
|
||||
reference: String,
|
||||
local_ref: Option<String>,
|
||||
status: String,
|
||||
source: Option<String>,
|
||||
package: Option<String>,
|
||||
version: Option<String>,
|
||||
digest: Option<String>,
|
||||
configured: bool,
|
||||
discovered: bool,
|
||||
resolved: bool,
|
||||
static_eligible: bool,
|
||||
declared_surfaces: Vec<String>,
|
||||
enabled_surfaces: Vec<String>,
|
||||
requested_permissions: Vec<String>,
|
||||
configured_grants: Vec<String>,
|
||||
tools: Vec<ToolSummary>,
|
||||
static_runtime: Option<PluginStaticInspection>,
|
||||
diagnostics: Vec<DiagnosticSummary>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
struct ToolSummary {
|
||||
name: String,
|
||||
permission: String,
|
||||
requested: bool,
|
||||
granted: bool,
|
||||
eligible: bool,
|
||||
external_write: bool,
|
||||
diagnostic: Option<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
package: Option<String>,
|
||||
version: Option<String>,
|
||||
digest: Option<String>,
|
||||
static_eligible: bool,
|
||||
declared_surfaces: Vec<String>,
|
||||
enabled_surfaces: Vec<String>,
|
||||
requested_permissions: Vec<String>,
|
||||
configured_grants: Vec<String>,
|
||||
tools: Vec<ToolSummary>,
|
||||
static_runtime: Option<PluginStaticInspection>,
|
||||
diagnostics: Vec<DiagnosticSummary>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
) -> 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<u8>, value: u16) {
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
|
||||
fn write_u32(bytes: &mut Vec<u8>, value: u32) {
|
||||
bytes.extend_from_slice(&value.to_le_bytes());
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user