diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 7ce8c3c52..329e19005 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -31,6 +31,7 @@ dependencies = [ "log", "mime_guess", "minisign-verify", + "ntapi", "nucleo-matcher", "objc2", "objc2-app-kit", @@ -74,6 +75,7 @@ dependencies = [ "url", "uuid", "which", + "winapi", "windows-sys 0.59.0", "yaml_serde", "zip 2.4.2", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 47256d672..53a8d7902 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -100,6 +100,8 @@ uuid = { version = "1", features = ["v4", "serde"] } zip = { version = "2", default-features = false, features = ["deflate"] } [target.'cfg(windows)'.dependencies] +ntapi = { version = "0.4.3", default-features = false } +winapi = { version = "0.3.9", default-features = false, features = ["fileapi", "winbase"] } windows-sys = { version = "0.59", features = [ "Win32_Foundation", "Win32_Globalization", diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 55584379c..276e336e7 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -48,6 +48,7 @@ pub mod runtime_config; pub mod security_threshold; pub mod siri_voice; pub mod skill_marketplace; +pub mod source_transfer; pub mod system; pub mod telemetry; pub mod terminal; diff --git a/src-tauri/src/commands/source_transfer.rs b/src-tauri/src/commands/source_transfer.rs new file mode 100644 index 000000000..bd194c8c4 --- /dev/null +++ b/src-tauri/src/commands/source_transfer.rs @@ -0,0 +1,1374 @@ +mod secure_read; + +use serde::Serialize; +use serde_json::Value; +use std::{ + collections::{HashMap, HashSet}, + env, + fs::{self, OpenOptions}, + io::{ErrorKind, Read, Write}, + path::{Component, Path, PathBuf}, +}; +use tauri::{AppHandle, Manager}; + +const SKILL_FILE_NAME: &str = "SKILL.md"; +const MAX_SKILL_FILE_BYTES: u64 = 1024 * 1024; +const MAX_SOURCE_IMPORT_BYTES: usize = 10 * 1024 * 1024; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExportSkillSourceResponse { + pub json: String, + pub filename: String, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ImportSourceResponse { + pub sources: Vec, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PortableSourceEntry { + #[serde(rename = "type")] + pub source_type: &'static str, + pub name: String, + pub description: String, + pub content: String, + pub path: String, + pub global: bool, + pub writable: bool, + #[serde(skip_serializing_if = "Vec::is_empty")] + pub supporting_files: Vec, + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub properties: HashMap, +} + +#[derive(Debug)] +struct SourceImportV1 { + name: String, + description: String, + content: String, + properties: HashMap, +} + +#[derive(serde::Deserialize)] +struct SkillFrontmatter { + name: Option, + #[serde(default)] + description: String, +} + +#[derive(serde::Deserialize)] +struct AgentFrontmatter { + #[serde(default)] + name: String, + #[serde(default)] + description: String, + #[serde(default, flatten)] + properties: HashMap, +} + +#[derive(Debug)] +struct AuthorizedSkillPath { + directory: PathBuf, + root: PathBuf, +} + +#[derive(Clone, serde::Deserialize)] +struct PluginConfigEntry { + enabled: bool, +} + +#[derive(Default, serde::Deserialize)] +struct PluginSettings { + #[serde(default, rename = "enabledPlugins")] + enabled: Vec, + #[serde(default, rename = "disabledPlugins")] + disabled: Vec, +} + +#[derive(Default, serde::Deserialize)] +struct OpenPluginManifest { + skills: Option, +} + +fn parse_frontmatter serde::Deserialize<'de>>( + content: &str, +) -> Result, yaml_serde::Error> { + let parts: Vec<&str> = content.split("---").collect(); + if parts.len() < 3 { + return Ok(None); + } + + let metadata = yaml_serde::from_str(parts[1].trim())?; + Ok(Some((metadata, parts[2..].join("---").trim().to_string()))) +} + +fn parse_source_import(data: &str, expected_type: &str) -> Result { + if data.len() > MAX_SOURCE_IMPORT_BYTES { + return Err("Source import data must be 10 MB or smaller".to_string()); + } + + let value: Value = serde_json::from_str(data).map_err(|err| format!("Invalid JSON: {err}"))?; + let version = value + .get("version") + .and_then(Value::as_u64) + .ok_or_else(|| "Missing or invalid \"version\" field".to_string())?; + if version != 1 { + return Err(format!("Unsupported source export version: {version}")); + } + + let source_type = value.get("type").and_then(Value::as_str).unwrap_or("skill"); + if source_type != expected_type { + return Err(format!( + "Source type '{source_type}' import is not supported by this operation." + )); + } + + let name = value + .get("name") + .and_then(Value::as_str) + .ok_or_else(|| "Missing or invalid \"name\" field".to_string())? + .to_string(); + if name.is_empty() { + return Err("Source name must not be empty".to_string()); + } + + let description = value + .get("description") + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + if expected_type == "skill" && description.is_empty() { + return Err("Source description must not be empty".to_string()); + } + + let content = value + .get("content") + .or_else(|| value.get("instructions")) + .and_then(Value::as_str) + .unwrap_or_default() + .to_string(); + let mut properties: HashMap = value + .get("properties") + .and_then(Value::as_object) + .map(|properties| { + properties + .iter() + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + }) + .unwrap_or_default(); + + if expected_type == "agent" { + if let Some(metadata) = value.get("metadata").and_then(Value::as_object) { + for (key, value) in metadata { + properties + .entry(key.clone()) + .or_insert_with(|| value.clone()); + } + } + } + + Ok(SourceImportV1 { + name, + description, + content, + properties, + }) +} + +fn validate_skill_name(name: &str) -> Result<(), String> { + if name.is_empty() { + return Err("Skill name must not be empty".to_string()); + } + if name.len() > 64 { + return Err(format!( + "Invalid skill name \"{name}\". Names must be at most 64 characters." + )); + } + if !name + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-') + { + return Err(format!( + "Invalid skill name \"{name}\". Names may only contain lowercase letters, digits, and hyphens." + )); + } + if name.starts_with('-') || name.ends_with('-') { + return Err(format!( + "Invalid skill name \"{name}\". Names must not start or end with a hyphen." + )); + } + Ok(()) +} + +fn validate_agent_name(name: &str) -> Result<(), String> { + let trimmed = name.trim(); + if trimmed.is_empty() { + return Err("Agent name must not be empty".to_string()); + } + if trimmed.len() > 80 { + return Err(format!( + "Invalid agent name \"{name}\". Names must be at most 80 characters." + )); + } + if trimmed.chars().any(|ch| matches!(ch, '/' | '\\')) { + return Err(format!( + "Invalid agent name \"{name}\". Names must not contain path separators." + )); + } + Ok(()) +} + +fn slugify_agent_name(name: &str) -> String { + let slug: String = name + .to_lowercase() + .chars() + .map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' }) + .collect(); + let mut collapsed = String::with_capacity(slug.len()); + let mut previous_hyphen = false; + for ch in slug.chars() { + if ch == '-' { + if !previous_hyphen { + collapsed.push('-'); + } + previous_hyphen = true; + } else { + collapsed.push(ch); + previous_hyphen = false; + } + } + + let trimmed = collapsed.trim_matches('-'); + if trimmed.is_empty() { + "agent".to_string() + } else { + trimmed + .chars() + .take(64) + .collect::() + .trim_end_matches('-') + .to_string() + } +} + +fn build_skill_markdown(source: &SourceImportV1, name: &str) -> Result { + let safe_description = source.description.replace('\'', "''"); + let mut markdown = format!("---\nname: {name}\ndescription: '{safe_description}'\n"); + if !source.properties.is_empty() { + markdown.push_str("metadata:\n"); + let yaml = yaml_serde::to_string(&source.properties) + .map_err(|err| format!("Failed to serialize source property: {err}"))?; + for line in yaml.lines().filter(|line| !line.is_empty()) { + markdown.push_str(" "); + markdown.push_str(line); + markdown.push('\n'); + } + } + markdown.push_str("---\n"); + if !source.content.is_empty() { + markdown.push('\n'); + markdown.push_str(&source.content); + markdown.push('\n'); + } + Ok(markdown) +} + +fn build_agent_markdown(source: &SourceImportV1) -> Result { + let mut frontmatter = yaml_serde::Mapping::new(); + frontmatter.insert( + yaml_serde::Value::String("name".to_string()), + yaml_serde::Value::String(source.name.clone()), + ); + frontmatter.insert( + yaml_serde::Value::String("description".to_string()), + yaml_serde::Value::String(source.description.clone()), + ); + for (key, value) in &source.properties { + if key == "name" || key == "description" { + continue; + } + let value = yaml_serde::to_value(value) + .map_err(|err| format!("Failed to serialize source property: {err}"))?; + frontmatter.insert(yaml_serde::Value::String(key.clone()), value); + } + let yaml = yaml_serde::to_string(&frontmatter) + .map_err(|err| format!("Failed to serialize source: {err}"))?; + let mut markdown = format!("---\n{yaml}---\n"); + if !source.content.is_empty() { + markdown.push('\n'); + markdown.push_str(&source.content); + markdown.push('\n'); + } + Ok(markdown) +} + +fn ensure_managed_root(root: &Path, label: &str) -> Result { + if root.exists() { + let metadata = fs::metadata(root) + .map_err(|err| format!("Failed to access {label} directory: {err}"))?; + if !metadata.is_dir() { + return Err(format!("{label} path must be a regular directory")); + } + } else { + fs::create_dir_all(root) + .map_err(|err| format!("Failed to create {label} directory: {err}"))?; + } + root.canonicalize() + .map_err(|err| format!("Failed to resolve {label} directory: {err}")) +} + +fn persist_new_file(path: &Path, contents: &str) -> Result<(), String> { + let parent = path + .parent() + .ok_or_else(|| "Source destination is missing a parent directory".to_string())?; + let mut temporary = tempfile::NamedTempFile::new_in(parent) + .map_err(|err| format!("Failed to create temporary source file: {err}"))?; + temporary + .write_all(contents.as_bytes()) + .map_err(|err| format!("Failed to write temporary source file: {err}"))?; + temporary + .as_file() + .sync_all() + .map_err(|err| format!("Failed to sync temporary source file: {err}"))?; + temporary + .persist_noclobber(path) + .map_err(|err| format!("Failed to persist source file: {}", err.error))?; + Ok(()) +} + +fn read_utf8_regular_file_nofollow( + path: &Path, + max_bytes: u64, + label: &str, +) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.custom_flags(libc::O_NOFOLLOW); + } + #[cfg(windows)] + { + use std::os::windows::fs::OpenOptionsExt; + // FILE_FLAG_OPEN_REPARSE_POINT keeps the final component from being + // followed so handle metadata can reject symbolic links. + options.custom_flags(0x0020_0000); + } + + let mut file = options + .open(path) + .map_err(|err| format!("Failed to read {label}: {err}"))?; + let metadata = file + .metadata() + .map_err(|err| format!("Failed to inspect {label}: {err}"))?; + if metadata.file_type().is_symlink() || !metadata.is_file() || metadata.len() > max_bytes { + return Err(format!("Failed to read {label}: invalid source file")); + } + + let mut bytes = Vec::with_capacity(metadata.len() as usize); + Read::by_ref(&mut file) + .take(max_bytes + 1) + .read_to_end(&mut bytes) + .map_err(|err| format!("Failed to read {label}: {err}"))?; + if bytes.len() as u64 > max_bytes { + return Err(format!("Failed to read {label}: invalid source file")); + } + String::from_utf8(bytes).map_err(|err| format!("Failed to read {label}: {err}")) +} + +fn next_skill_name(name: &str, index: u32) -> String { + match index { + 0 => name.to_string(), + 1 => format!("{name}-imported"), + _ => format!("{name}-imported-{index}"), + } +} + +fn import_skill_at(root: &Path, data: &str) -> Result { + let source = parse_source_import(data, "skill")?; + validate_skill_name(&source.name)?; + let root = ensure_managed_root(root, "Personal skills")?; + + let mut index = 0; + let (name, skill_dir) = loop { + let name = next_skill_name(&source.name, index); + let skill_dir = root.join(&name); + match fs::create_dir(&skill_dir) { + Ok(()) => match build_skill_markdown(&source, &name) { + Ok(markdown) => { + if let Err(error) = + persist_new_file(&skill_dir.join(SKILL_FILE_NAME), &markdown) + { + let _ = fs::remove_dir(&skill_dir); + return Err(error.replace("source file", "SKILL.md")); + } + break (name, skill_dir); + } + Err(error) => { + let _ = fs::remove_dir(&skill_dir); + return Err(error); + } + }, + Err(error) if error.kind() == ErrorKind::AlreadyExists => index += 1, + Err(error) => { + return Err(format!("Failed to create skill directory: {error}")); + } + } + }; + + Ok(PortableSourceEntry { + source_type: "skill", + name, + description: source.description, + content: source.content, + path: skill_dir.to_string_lossy().into_owned(), + global: true, + writable: true, + supporting_files: Vec::new(), + properties: source.properties, + }) +} + +fn import_agent_at(root: &Path, data: &str) -> Result { + let source = parse_source_import(data, "agent")?; + validate_agent_name(&source.name)?; + let root = ensure_managed_root(root, "Personal agents")?; + let slug = slugify_agent_name(&source.name); + let markdown = build_agent_markdown(&source)?; + + let mut index = 1u32; + let file_path = loop { + let filename = if index == 1 { + format!("{slug}.md") + } else { + format!("{slug}-{index}.md") + }; + let path = root.join(filename); + match persist_new_file(&path, &markdown) { + Ok(()) => break path, + Err(_error) if path.exists() => index += 1, + Err(error) => return Err(error), + } + }; + + let persisted = + read_utf8_regular_file_nofollow(&file_path, markdown.len() as u64, "imported agent") + .and_then(|markdown| { + parse_frontmatter::(&markdown) + .map_err(|err| format!("Invalid imported agent frontmatter: {err}"))? + .ok_or_else(|| "Imported agent file is missing frontmatter".to_string()) + }); + let (frontmatter, content) = match persisted { + Ok(persisted) => persisted, + Err(error) => { + let _ = fs::remove_file(&file_path); + return Err(error); + } + }; + + Ok(PortableSourceEntry { + source_type: "agent", + name: frontmatter.name, + description: frontmatter.description, + content, + path: file_path.to_string_lossy().into_owned(), + global: true, + writable: true, + supporting_files: Vec::new(), + properties: frontmatter.properties, + }) +} + +fn inferred_managed_skill_root(path: &Path) -> Option<&Path> { + path.ancestors().find(|ancestor| { + let Some(parent_name) = ancestor + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + else { + return false; + }; + match ancestor.file_name().and_then(|name| name.to_str()) { + Some("skills") => matches!(parent_name, ".agents" | ".goose" | ".claude"), + _ => false, + } + }) +} + +#[cfg(unix)] +fn system_goose_config_path() -> PathBuf { + PathBuf::from("/etc/goose/config.yaml") +} + +#[cfg(windows)] +fn system_goose_config_path() -> PathBuf { + env::var_os("PROGRAMDATA") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")) + .join("goose") + .join("config.yaml") +} + +fn configured_plugins_from_paths( + config_paths: impl IntoIterator, +) -> Result, String> { + if let Some(value) = env::var_os("PLUGINS") { + let value = value + .into_string() + .map_err(|_| "Goose PLUGINS configuration is not valid UTF-8".to_string())?; + let entries: HashMap = serde_json::from_str(&value) + .map_err(|err| format!("Invalid Goose PLUGINS configuration: {err}"))?; + return Ok(entries + .into_iter() + .map(|(path, entry)| (PathBuf::from(path), entry)) + .collect()); + } + + Ok(configured_plugins_from_files(config_paths)) +} + +fn configured_plugins_from_files( + config_paths: impl IntoIterator, +) -> HashMap { + let mut merged = HashMap::new(); + for path in config_paths { + let contents = match fs::read_to_string(&path) { + Ok(contents) => contents, + Err(_) => continue, + }; + let Ok(mapping) = yaml_serde::from_str::(&contents) else { + continue; + }; + if let Some(value) = mapping.get(yaml_serde::Value::String("plugins".to_string())) { + let Ok(entries) = + yaml_serde::from_value::>(value.clone()) + else { + continue; + }; + merged = entries; + } + } + + merged + .into_iter() + .map(|(path, entry)| (PathBuf::from(path), entry)) + .collect() +} + +fn configured_plugins() -> Result, String> { + let mut paths = vec![system_goose_config_path()]; + if let Some(value) = env::var_os(crate::services::goose_config::ADDITIONAL_CONFIG_FILES_ENV) { + paths.extend(env::split_paths(&value)); + } + paths.push(crate::services::goose_config::config_path()?); + configured_plugins_from_paths(paths) +} + +fn plugin_settings_at(path: &Path) -> Option { + fs::read_to_string(path) + .ok() + .and_then(|contents| serde_json::from_str(&contents).ok()) +} + +fn user_plugin_is_enabled_by_settings(plugin_dir: &Path, user_settings_path: &Path) -> bool { + let Some(plugin_name) = plugin_dir.file_name().and_then(|name| name.to_str()) else { + return false; + }; + if let Some(settings) = plugin_settings_at(user_settings_path) { + if settings.disabled.iter().any(|name| name == plugin_name) { + return false; + } + if settings.enabled.iter().any(|name| name == plugin_name) { + return true; + } + } + true +} + +fn equivalent_paths(left: &Path, right: &Path) -> bool { + left == right + || left + .canonicalize() + .ok() + .zip(right.canonicalize().ok()) + .is_some_and(|(left, right)| left == right) +} + +fn configured_plugin_enabled( + plugin_dir: &Path, + configured: &HashMap, +) -> Option { + configured + .get(plugin_dir) + .or_else(|| { + configured + .iter() + .find_map(|(path, entry)| equivalent_paths(path, plugin_dir).then_some(entry)) + }) + .map(|entry| entry.enabled) +} + +fn valid_relative_plugin_path(path: &str) -> Option { + if !path.starts_with("./") { + return None; + } + let path = PathBuf::from(path); + (!path.is_absolute() + && !path + .components() + .any(|component| matches!(component, Component::ParentDir))) + .then_some(path) +} + +fn plugin_component_paths(value: &Value) -> Option<(Vec, bool)> { + match value { + Value::Null => Some((Vec::new(), false)), + Value::String(path) => Some((vec![path.clone()], false)), + Value::Array(paths) => Some(( + paths + .iter() + .map(Value::as_str) + .map(|path| path.map(str::to_string)) + .collect::>>()?, + false, + )), + Value::Object(config) => { + let paths = match config.get("paths") { + None => Vec::new(), + Some(Value::String(path)) => vec![path.clone()], + Some(Value::Array(paths)) => paths + .iter() + .map(Value::as_str) + .map(|path| path.map(str::to_string)) + .collect::>>()?, + Some(_) => return None, + }; + let exclusive = match config.get("exclusive") { + None => false, + Some(Value::Bool(exclusive)) => *exclusive, + Some(_) => return None, + }; + Some((paths, exclusive)) + } + _ => None, + } +} + +fn open_plugin_manifest(plugin_dir: &Path) -> Option { + let manifest = [ + ".goose-plugin/plugin.json", + ".plugin/plugin.json", + "plugin.json", + ] + .into_iter() + .map(|path| plugin_dir.join(path)) + .find(|path| path.is_file()); + + match manifest { + Some(path) => serde_json::from_str(&fs::read_to_string(path).ok()?).ok(), + None => Some(OpenPluginManifest::default()), + } +} + +fn installed_plugin_skill_roots(plugin_dir: &Path) -> Vec { + let Some(manifest) = open_plugin_manifest(plugin_dir) else { + return Vec::new(); + }; + let (custom_paths, exclusive) = match manifest.skills.as_ref() { + Some(value) => match plugin_component_paths(value) { + Some(paths) => paths, + None => return Vec::new(), + }, + None => (Vec::new(), false), + }; + + let mut roots = Vec::new(); + if !exclusive { + roots.push(plugin_dir.join("skills")); + } + for path in custom_paths { + let Some(path) = valid_relative_plugin_path(&path) else { + return Vec::new(); + }; + roots.push(plugin_dir.join(path)); + } + if !plugin_dir.join("skills").is_dir() + && manifest.skills.is_none() + && plugin_dir.join(SKILL_FILE_NAME).is_file() + { + roots.push(plugin_dir.to_path_buf()); + } + + let mut seen = HashSet::new(); + roots + .into_iter() + .filter(|path| path.is_dir()) + .filter(|path| seen.insert(path.canonicalize().unwrap_or_else(|_| (*path).clone()))) + .collect() +} + +fn enabled_plugin_skill_roots_at( + agents_root: &Path, + user_settings_path: &Path, + configured: &HashMap, +) -> Vec { + let user_plugins_dir = agents_root.join("plugins"); + let plugin_dirs = fs::read_dir(&user_plugins_dir) + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.is_dir()) + .filter(|path| configured_plugin_enabled(path, configured).unwrap_or(true)) + .collect::>(); + + let mut seen = HashSet::new(); + plugin_dirs + .into_iter() + .filter(|path| user_plugin_is_enabled_by_settings(path, user_settings_path)) + .flat_map(|path| installed_plugin_skill_roots(&path)) + .filter(|path| seen.insert(path.clone())) + .collect() +} + +fn is_agents_plugin_path(path: &Path) -> bool { + path.ancestors().any(|ancestor| { + ancestor.file_name().and_then(|name| name.to_str()) == Some("plugins") + && ancestor + .parent() + .and_then(Path::file_name) + .and_then(|name| name.to_str()) + == Some(".agents") + }) +} + +fn validate_export_skill_path( + path: &Path, + trusted_roots: &[PathBuf], +) -> Result { + if path.as_os_str().is_empty() { + return Err("Source path must not be empty".to_string()); + } + let metadata = fs::symlink_metadata(path) + .map_err(|_| format!("Source \"{}\" not found", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_dir() { + return Err(format!("Source \"{}\" not found", path.display())); + } + let canonical_dir = path + .canonicalize() + .map_err(|_| format!("Source \"{}\" not found", path.display()))?; + let trusted_roots = trusted_roots + .iter() + .filter_map(|root| { + let canonical_root = root.canonicalize().ok()?; + canonical_dir + .starts_with(&canonical_root) + .then(|| (root.clone(), canonical_root, is_agents_plugin_path(root))) + }) + .collect::>(); + let lexical_plugin_path = is_agents_plugin_path(path); + let canonical_plugin_path = is_agents_plugin_path(&canonical_dir); + let authorized_root = if lexical_plugin_path { + trusted_roots + .iter() + .find(|(lexical_root, _, plugin_root)| *plugin_root && path.starts_with(lexical_root)) + .map(|(_, root, _)| root.clone()) + } else if canonical_plugin_path { + trusted_roots + .iter() + .find(|(_, _, plugin_root)| *plugin_root) + .map(|(_, root, _)| root.clone()) + } else { + trusted_roots + .first() + .map(|(_, root, _)| root.clone()) + .or_else(|| inferred_managed_skill_root(&canonical_dir).map(Path::to_path_buf)) + }; + let Some(root) = authorized_root else { + return Err(format!("Source \"{}\" not found", path.display())); + }; + Ok(AuthorizedSkillPath { + directory: canonical_dir, + root, + }) +} + +fn export_skill_at( + path: &Path, + trusted_roots: &[PathBuf], +) -> Result { + let skill_path = validate_export_skill_path(path, trusted_roots)?; + let relative = skill_path + .directory + .strip_prefix(&skill_path.root) + .map_err(|_| format!("Source \"{}\" not found", path.display()))? + .join(SKILL_FILE_NAME); + let raw = secure_read::read_confined_utf8(&skill_path.root, &relative, MAX_SKILL_FILE_BYTES) + .map_err(|err| format!("Failed to read {SKILL_FILE_NAME}: {err}"))?; + let parsed = parse_frontmatter::(&raw); + let frontmatter_name = parsed + .as_ref() + .ok() + .and_then(Option::as_ref) + .and_then(|(frontmatter, _)| frontmatter.name.clone()); + let (description, content) = match parsed { + Ok(Some((frontmatter, content))) if raw.trim_start().starts_with("---") => { + (frontmatter.description, content) + } + _ => (String::new(), raw), + }; + let name = frontmatter_name + .filter(|name| !name.is_empty()) + .or_else(|| { + skill_path + .directory + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) + }) + .unwrap_or_else(|| "unnamed".to_string()); + let json = serde_json::to_string_pretty(&serde_json::json!({ + "version": 1, + "type": "skill", + "name": name, + "description": description, + "content": content, + })) + .map_err(|err| format!("Failed to serialize source: {err}"))?; + + Ok(ExportSkillSourceResponse { + filename: format!("{name}.skill.json"), + json, + }) +} + +fn export_skill_roots(app: &AppHandle) -> Result, String> { + let mut roots = Vec::new(); + let agents_root = crate::services::goose_config::agents_root()?; + roots.push(agents_root.join("skills")); + roots.push(crate::services::goose_config::config_dir()?.join("skills")); + + // Plugin paths are authorization roots, so malformed configuration fails + // closed for plugins without blocking exports from ordinary skill roots. + if let Ok(configured) = configured_plugins() { + let user_settings = if let Some(path_root) = crate::services::goose_config::path_root() { + path_root.join(".config/goose/settings.json") + } else { + agents_root + .parent() + .ok_or_else(|| "Could not determine home directory".to_string())? + .join(".config/goose/settings.json") + }; + roots.extend(enabled_plugin_skill_roots_at( + &agents_root, + &user_settings, + &configured, + )); + } + + if crate::services::goose_config::path_root().is_none() { + let home = agents_root + .parent() + .ok_or_else(|| "Could not determine home directory".to_string())?; + roots.extend([ + home.join(".goose").join("skills"), + home.join(".claude").join("skills"), + home.join(".codex").join("skills"), + home.join(".gemini").join("skills"), + home.join(".config").join("agents").join("skills"), + ]); + } + roots.push( + app.path() + .app_data_dir() + .map_err(|err| format!("Failed to resolve Berd app data directory: {err}"))? + .join("skills"), + ); + Ok(roots) +} + +fn personal_skills_root() -> Result { + Ok(crate::services::goose_config::agents_root()?.join("skills")) +} + +fn personal_agents_root() -> Result { + Ok(crate::services::goose_config::agents_root()?.join("agents")) +} + +#[tauri::command] +pub fn export_skill_source( + app: AppHandle, + path: String, +) -> Result { + export_skill_at(Path::new(&path), &export_skill_roots(&app)?) +} + +#[tauri::command] +pub fn import_skill_source(data: String) -> Result { + let source = import_skill_at(&personal_skills_root()?, &data)?; + Ok(ImportSourceResponse { + sources: vec![source], + }) +} + +#[tauri::command] +pub fn import_agent_source(data: String) -> Result { + let source = import_agent_at(&personal_agents_root()?, &data)?; + Ok(ImportSourceResponse { + sources: vec![source], + }) +} + +#[cfg(test)] +mod tests { + use super::{ + configured_plugins_from_files, enabled_plugin_skill_roots_at, export_skill_at, + import_agent_at, import_skill_at, parse_source_import, validate_export_skill_path, + PluginConfigEntry, MAX_SKILL_FILE_BYTES, SKILL_FILE_NAME, + }; + use serde_json::Value; + use std::{collections::HashMap, fs}; + use tempfile::TempDir; + + fn skill_json(name: &str) -> String { + serde_json::json!({ + "version": 1, + "type": "skill", + "name": name, + "description": "Reviews code", + "content": "Review carefully", + "properties": { "color": "blue" } + }) + .to_string() + } + + #[test] + fn skill_import_preserves_v1_shape_and_collision_names() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join(".agents").join("skills"); + + let first = import_skill_at(&root, &skill_json("code-review")).unwrap(); + let second = import_skill_at(&root, &skill_json("code-review")).unwrap(); + let third = import_skill_at(&root, &skill_json("code-review")).unwrap(); + + assert_eq!(first.name, "code-review"); + assert_eq!(second.name, "code-review-imported"); + assert_eq!(third.name, "code-review-imported-2"); + assert_eq!( + first.properties.get("color"), + Some(&Value::String("blue".into())) + ); + let markdown = fs::read_to_string(root.join("code-review").join("SKILL.md")).unwrap(); + assert!(markdown.contains("metadata:\n color: blue\n")); + let frontmatter = markdown.split("---").nth(1).unwrap(); + let parsed: yaml_serde::Value = yaml_serde::from_str(frontmatter).unwrap(); + assert_eq!(parsed["metadata"]["color"].as_str(), Some("blue")); + } + + #[test] + fn agent_import_preserves_metadata_and_uses_numbered_collision_names() { + let temp = TempDir::new().unwrap(); + let root = temp.path().join(".agents").join("agents"); + let data = serde_json::json!({ + "version": 1, + "type": "agent", + "name": "Research Helper", + "description": "Finds evidence", + "content": " \nResearch carefully\n\n", + "properties": { + "avatar": "app-avatar:gloopy-1", + "color": "blue", + "name": "Reserved name", + "description": "Reserved description" + }, + "metadata": { "color": "red", "tone": "direct" } + }) + .to_string(); + + let first = import_agent_at(&root, &data).unwrap(); + let second = import_agent_at(&root, &data).unwrap(); + + assert!(first.path.ends_with("research-helper.md")); + assert!(second.path.ends_with("research-helper-2.md")); + assert_eq!( + first.properties.get("color"), + Some(&Value::String("blue".into())) + ); + assert_eq!( + first.properties.get("tone"), + Some(&Value::String("direct".into())) + ); + assert_eq!( + first.properties.get("avatar"), + Some(&Value::String("app-avatar:gloopy-1".into())) + ); + assert_eq!(first.content, "Research carefully"); + assert!(!first.properties.contains_key("name")); + assert!(!first.properties.contains_key("description")); + let markdown = fs::read_to_string(root.join("research-helper.md")).unwrap(); + assert!(markdown.contains("avatar: app-avatar:gloopy-1")); + assert!(!markdown.contains("Reserved name")); + assert!(!markdown.contains("Reserved description")); + } + + #[test] + fn export_reads_managed_project_skill_and_reproduces_v1_payload() { + let temp = TempDir::new().unwrap(); + let skill_dir = temp + .path() + .join("project") + .join(".agents") + .join("skills") + .join("code-review"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: code-review\ndescription: Reviews code\nmetadata:\n color: blue\n---\n\nReview carefully.\n", + ) + .unwrap(); + + let export = export_skill_at(&skill_dir, &[]).unwrap(); + let value: Value = serde_json::from_str(&export.json).unwrap(); + + assert_eq!(export.filename, "code-review.skill.json"); + assert_eq!(value["version"], 1); + assert_eq!(value["type"], "skill"); + assert_eq!(value["description"], "Reviews code"); + assert_eq!(value["content"], "Review carefully."); + assert!(value.get("properties").is_none()); + } + + #[test] + fn export_allows_nested_skills_and_requires_exact_plugin_skill_roots() { + let temp = TempDir::new().unwrap(); + let arbitrary = temp.path().join("secrets"); + fs::create_dir(&arbitrary).unwrap(); + fs::write(arbitrary.join("SKILL.md"), "private").unwrap(); + + assert!(validate_export_skill_path(&arbitrary, &[]).is_err()); + + let nested = temp + .path() + .join("project") + .join(".agents") + .join("skills") + .join("outer") + .join("nested"); + fs::create_dir_all(&nested).unwrap(); + fs::write(nested.join("SKILL.md"), "nested skill").unwrap(); + assert!(export_skill_at(&nested, &[]).is_ok()); + + let plugin_skill = temp + .path() + .join(".agents") + .join("plugins") + .join("plugin-name") + .join("custom-skills") + .join("plugin-skill"); + fs::create_dir_all(&plugin_skill).unwrap(); + fs::write(plugin_skill.join("SKILL.md"), "plugin skill").unwrap(); + assert!(export_skill_at(&plugin_skill, &[]).is_err()); + assert!(export_skill_at( + &plugin_skill, + &[plugin_skill.parent().unwrap().to_path_buf()] + ) + .is_ok()); + } + + #[test] + fn plugin_exports_use_enabled_manifest_skill_roots() { + let temp = TempDir::new().unwrap(); + let agents_root = temp.path().join(".agents"); + let user_plugins = agents_root.join("plugins"); + let user_settings = temp.path().join(".config/goose/settings.json"); + + for name in ["enabled", "config-disabled", "settings-disabled"] { + fs::create_dir_all(user_plugins.join(name).join("skills")).unwrap(); + } + fs::create_dir_all(user_settings.parent().unwrap()).unwrap(); + fs::write( + &user_settings, + r#"{"disabledPlugins":["settings-disabled"]}"#, + ) + .unwrap(); + + let custom_plugin = user_plugins.join("custom"); + fs::create_dir_all(custom_plugin.join("custom-skills")).unwrap(); + fs::write( + custom_plugin.join("plugin.json"), + r#"{"skills":{"paths":["./custom-skills"],"exclusive":true}}"#, + ) + .unwrap(); + let project_plugin = temp.path().join("project/.agents/plugins/configured"); + fs::create_dir_all(project_plugin.join("skills")).unwrap(); + + let configured = HashMap::from([ + ( + user_plugins.join("config-disabled"), + PluginConfigEntry { enabled: false }, + ), + (project_plugin.clone(), PluginConfigEntry { enabled: true }), + ]); + let roots = enabled_plugin_skill_roots_at(&agents_root, &user_settings, &configured); + + assert!(roots.contains(&user_plugins.join("enabled/skills"))); + assert!(roots.contains(&custom_plugin.join("custom-skills"))); + assert!(!roots + .iter() + .any(|root| root.starts_with(user_plugins.join("config-disabled")))); + assert!(!roots + .iter() + .any(|root| root.starts_with(user_plugins.join("settings-disabled")))); + assert!(!roots.iter().any(|root| root.starts_with(&project_plugin))); + } + + #[test] + fn plugin_paths_cannot_bypass_authorization_with_nested_managed_roots() { + let temp = TempDir::new().unwrap(); + let agents_root = temp.path().join(".agents"); + let user_plugins = agents_root.join("plugins"); + let disabled_plugin = user_plugins.join("disabled"); + let project_plugin = temp.path().join("project/.agents/plugins/untrusted"); + let enabled_plugin = user_plugins.join("enabled"); + let enabled_skill = enabled_plugin.join("skills/exportable"); + + fs::create_dir_all(&enabled_skill).unwrap(); + fs::write(enabled_skill.join(SKILL_FILE_NAME), "enabled").unwrap(); + let configured = HashMap::from([( + disabled_plugin.clone(), + PluginConfigEntry { enabled: false }, + )]); + let trusted_roots = enabled_plugin_skill_roots_at( + &agents_root, + &temp.path().join("missing-settings.json"), + &configured, + ); + + assert!(export_skill_at(&enabled_skill, &trusted_roots).is_ok()); + for manager in [".agents", ".goose", ".claude"] { + for plugin in [&disabled_plugin, &project_plugin] { + let bypass = plugin.join("nested").join(manager).join("skills/bypass"); + fs::create_dir_all(&bypass).unwrap(); + fs::write(bypass.join(SKILL_FILE_NAME), "private").unwrap(); + + assert!(export_skill_at(&bypass, &trusted_roots).is_err()); + } + } + } + + #[cfg(unix)] + #[test] + fn project_plugin_symlink_cannot_alias_an_enabled_user_plugin_root() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let agents_root = temp.path().join(".agents"); + let enabled_plugin = agents_root.join("plugins/enabled"); + let enabled_skill = enabled_plugin.join("skills/exportable"); + fs::create_dir_all(&enabled_skill).unwrap(); + fs::write(enabled_skill.join(SKILL_FILE_NAME), "enabled").unwrap(); + let trusted_roots = enabled_plugin_skill_roots_at( + &agents_root, + &temp.path().join("missing-settings.json"), + &HashMap::new(), + ); + assert!(export_skill_at(&enabled_skill, &trusted_roots).is_ok()); + + let project_plugins = temp.path().join("project/.agents/plugins"); + fs::create_dir_all(&project_plugins).unwrap(); + let alias = project_plugins.join("alias"); + symlink(&enabled_plugin, &alias).unwrap(); + + assert!(export_skill_at(&alias.join("skills/exportable"), &trusted_roots).is_err()); + } + + #[cfg(unix)] + #[test] + fn enabled_user_plugin_symlink_can_target_an_ordinary_skill_root() { + use std::os::unix::fs::symlink; + + let temp = TempDir::new().unwrap(); + let agents_root = temp.path().join(".agents"); + let ordinary_root = agents_root.join("skills"); + let plugin_target = ordinary_root.join("plugin-target"); + let target_skill = plugin_target.join("skills/exportable"); + fs::create_dir_all(&target_skill).unwrap(); + fs::write(target_skill.join(SKILL_FILE_NAME), "enabled").unwrap(); + + let plugins_root = agents_root.join("plugins"); + fs::create_dir_all(&plugins_root).unwrap(); + let enabled_plugin = plugins_root.join("enabled"); + symlink(&plugin_target, &enabled_plugin).unwrap(); + let requested_skill = enabled_plugin.join("skills/exportable"); + + let mut trusted_roots = vec![ordinary_root]; + trusted_roots.extend(enabled_plugin_skill_roots_at( + &agents_root, + &temp.path().join("missing-settings.json"), + &HashMap::new(), + )); + + assert!(export_skill_at(&requested_skill, &trusted_roots).is_ok()); + } + + #[test] + fn plugin_config_skips_invalid_layers_without_hiding_valid_layers() { + let temp = TempDir::new().unwrap(); + let invalid = temp.path().join("invalid.yaml"); + let valid = temp.path().join("valid.yaml"); + let plugin = temp.path().join(".agents/plugins/example"); + fs::write(&invalid, "plugins: [").unwrap(); + fs::write( + &valid, + format!("plugins:\n '{}':\n enabled: false\n", plugin.display()), + ) + .unwrap(); + + for paths in [[invalid.clone(), valid.clone()], [valid, invalid]] { + let configured = configured_plugins_from_files(paths); + assert!(configured.get(&plugin).is_some_and(|entry| !entry.enabled)); + } + } + + #[test] + fn export_accepts_one_mebibyte_skill_and_rejects_one_byte_more() { + let temp = TempDir::new().unwrap(); + let skill_dir = temp.path().join("project/.agents/skills/boundary"); + fs::create_dir_all(&skill_dir).unwrap(); + let skill_file = skill_dir.join(SKILL_FILE_NAME); + + fs::write(&skill_file, vec![b'x'; MAX_SKILL_FILE_BYTES as usize]).unwrap(); + assert!(export_skill_at(&skill_dir, &[]).is_ok()); + + fs::write(&skill_file, vec![b'x'; MAX_SKILL_FILE_BYTES as usize + 1]).unwrap(); + assert!(export_skill_at(&skill_dir, &[]).is_err()); + } + + #[cfg(unix)] + #[test] + fn secure_read_stays_in_opened_ancestor_after_symlink_swap() { + use std::os::unix::fs::symlink; + use std::path::Path; + + let temp = TempDir::new().unwrap(); + let root = temp.path().join("project/.agents/skills"); + fs::create_dir_all(&root).unwrap(); + let root = root.canonicalize().unwrap(); + let original = root.join("outer"); + let moved = root.join("moved"); + let outside = temp.path().join("outside"); + fs::create_dir_all(original.join("nested")).unwrap(); + fs::create_dir_all(outside.join("nested")).unwrap(); + fs::write(original.join("nested/SKILL.md"), "safe").unwrap(); + fs::write(outside.join("nested/SKILL.md"), "secret").unwrap(); + + let mut swapped = false; + let raw = super::secure_read::read_confined_utf8_for_test( + &root, + Path::new("outer/nested/SKILL.md"), + MAX_SKILL_FILE_BYTES, + |opened| { + if !swapped && opened == Path::new("outer") { + fs::rename(&original, &moved).unwrap(); + symlink(&outside, &original).unwrap(); + swapped = true; + } + }, + ) + .unwrap(); + + assert!(swapped); + assert_eq!(raw, "safe"); + } + + #[test] + fn export_rejects_symlinked_skill_files() { + let temp = TempDir::new().unwrap(); + let arbitrary = temp.path().join("secrets"); + fs::create_dir(&arbitrary).unwrap(); + fs::write(arbitrary.join("SKILL.md"), "private").unwrap(); + + #[cfg(unix)] + { + use std::os::unix::fs::symlink; + let skill_dir = temp + .path() + .join("project") + .join(".agents") + .join("skills") + .join("linked"); + fs::create_dir_all(&skill_dir).unwrap(); + symlink(arbitrary.join("SKILL.md"), skill_dir.join("SKILL.md")).unwrap(); + assert!(export_skill_at(&skill_dir, &[]).is_err()); + } + } + + #[test] + fn export_treats_malformed_or_non_leading_frontmatter_as_plain_content() { + let temp = TempDir::new().unwrap(); + let skills_root = temp.path().join("project").join(".goose").join("skills"); + + for (name, expected_name, markdown) in [ + ("malformed", "malformed", "---\nname: [\n---\nprivate"), + ( + "non-leading", + "spoofed", + "intro\n---\nname: spoofed\ndescription: hidden\n---\nprivate", + ), + ] { + let skill_dir = skills_root.join(name); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write(skill_dir.join("SKILL.md"), markdown).unwrap(); + let export = export_skill_at(&skill_dir, &[]).unwrap(); + let value: Value = serde_json::from_str(&export.json).unwrap(); + + assert_eq!(value["name"], expected_name); + assert_eq!(value["description"], ""); + assert_eq!(value["content"], markdown); + } + } + + #[test] + fn export_does_not_infer_project_codex_or_gemini_roots() { + let temp = TempDir::new().unwrap(); + + for manager in [".codex", ".gemini"] { + let root = temp.path().join("project").join(manager).join("skills"); + let skill_dir = root.join("private"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write(skill_dir.join(SKILL_FILE_NAME), "private").unwrap(); + + assert!(export_skill_at(&skill_dir, &[]).is_err()); + assert!(export_skill_at(&skill_dir, &[root]).is_ok()); + } + } + + #[test] + fn imports_reject_wrong_type_invalid_version_and_unsafe_names() { + assert!( + parse_source_import(r#"{"version":1,"type":"project","name":"x"}"#, "skill").is_err() + ); + assert!(parse_source_import( + r#"{"version":2,"type":"skill","name":"x","description":"x"}"#, + "skill" + ) + .is_err()); + + let temp = TempDir::new().unwrap(); + assert!(import_skill_at(temp.path(), &skill_json("../escape")).is_err()); + let agent = serde_json::json!({ + "version": 1, + "type": "agent", + "name": "../escape", + "content": "x" + }) + .to_string(); + assert!(import_agent_at(temp.path(), &agent).is_err()); + } +} diff --git a/src-tauri/src/commands/source_transfer/secure_read.rs b/src-tauri/src/commands/source_transfer/secure_read.rs new file mode 100644 index 000000000..3f5b505fe --- /dev/null +++ b/src-tauri/src/commands/source_transfer/secure_read.rs @@ -0,0 +1,407 @@ +use std::{ + ffi::OsStr, + fs, + io::{self, Read}, + path::{Component, Path}, +}; + +pub(super) fn read_confined_utf8( + root: &Path, + relative: &Path, + max_bytes: u64, +) -> io::Result { + read_confined_utf8_with_hook(root, relative, max_bytes, |_| {}) +} + +fn validated_relative_components(path: &Path) -> io::Result> { + let mut components = Vec::new(); + for component in path.components() { + match component { + Component::Normal(component) => components.push(component), + Component::CurDir => {} + Component::ParentDir | Component::RootDir | Component::Prefix(_) => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "source file path must stay within the authorized root", + )); + } + } + } + if components.is_empty() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "source file path must name a file", + )); + } + Ok(components) +} + +fn read_opened_file(mut file: fs::File, max_bytes: u64) -> io::Result { + let metadata = file.metadata()?; + if !metadata.is_file() || metadata.len() > max_bytes { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("source file exceeds the maximum encoded size of {max_bytes} bytes"), + )); + } + + let mut bytes = Vec::with_capacity(metadata.len() as usize); + file.by_ref().take(max_bytes + 1).read_to_end(&mut bytes)?; + if bytes.len() as u64 > max_bytes { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!("source file exceeds the maximum encoded size of {max_bytes} bytes"), + )); + } + String::from_utf8(bytes).map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error)) +} + +#[cfg(any(target_os = "linux", target_os = "android"))] +fn directory_traversal_flags() -> libc::c_int { + libc::O_PATH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC +} + +#[cfg(all( + unix, + any( + target_vendor = "apple", + target_os = "aix", + target_os = "freebsd", + target_os = "illumos", + target_os = "netbsd", + target_os = "solaris" + ) +))] +fn directory_traversal_flags() -> libc::c_int { + libc::O_SEARCH | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC +} + +#[cfg(all( + unix, + not(any( + target_vendor = "apple", + target_os = "aix", + target_os = "android", + target_os = "freebsd", + target_os = "illumos", + target_os = "linux", + target_os = "netbsd", + target_os = "solaris" + )) +))] +fn directory_traversal_flags() -> libc::c_int { + libc::O_RDONLY | libc::O_DIRECTORY | libc::O_NOFOLLOW | libc::O_CLOEXEC +} + +#[cfg(unix)] +fn open_root(root: &Path, after_opened_component: &mut impl FnMut(&Path)) -> io::Result { + use std::os::unix::fs::OpenOptionsExt; + + let mut options = fs::OpenOptions::new(); + options.read(true).custom_flags(directory_traversal_flags()); + let mut directory = options.open(Path::new("/"))?; + let mut opened_path = std::path::PathBuf::from("/"); + let mut saw_root = false; + for component in root.components() { + match component { + Component::RootDir if !saw_root => saw_root = true, + Component::Normal(component) if saw_root => { + directory = open_at(&directory, component, directory_traversal_flags())?; + opened_path.push(component); + after_opened_component(&opened_path); + } + Component::CurDir if saw_root => {} + _ => { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "authorized root must be an absolute normalized path", + )); + } + } + } + if !saw_root || opened_path != root || !directory.metadata()?.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "authorized root is not an absolute regular directory", + )); + } + Ok(directory) +} + +#[cfg(unix)] +fn read_confined_utf8_with_hook( + root: &Path, + relative: &Path, + max_bytes: u64, + mut after_opened_component: impl FnMut(&Path), +) -> io::Result { + let components = validated_relative_components(relative)?; + let (file_name, ancestors) = components.split_last().unwrap(); + let mut directory = open_root(root, &mut after_opened_component)?; + + let mut opened_path = std::path::PathBuf::new(); + for ancestor in ancestors { + directory = open_at(&directory, ancestor, directory_traversal_flags())?; + opened_path.push(ancestor); + after_opened_component(&opened_path); + } + + let file = open_at( + &directory, + file_name, + libc::O_RDONLY | libc::O_NONBLOCK | libc::O_NOFOLLOW | libc::O_CLOEXEC, + )?; + read_opened_file(file, max_bytes) +} + +#[cfg(unix)] +fn open_at(directory: &fs::File, name: &OsStr, flags: libc::c_int) -> io::Result { + use std::ffi::CString; + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + + let name = CString::new(name.as_bytes()).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "source file path contains a NUL byte", + ) + })?; + // SAFETY: openat does not retain the name pointer, and no creation flag requiring a mode is set. + let descriptor = unsafe { libc::openat(directory.as_raw_fd(), name.as_ptr(), flags) }; + if descriptor < 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: openat returned a new owned descriptor on success. + Ok(unsafe { fs::File::from_raw_fd(descriptor) }) +} + +#[cfg(windows)] +fn open_root(root: &Path, after_opened_component: &mut impl FnMut(&Path)) -> io::Result { + use std::os::windows::fs::OpenOptionsExt; + use winapi::um::winbase::{FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT}; + use winapi::um::winnt::{ + FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, + SYNCHRONIZE, + }; + + let root_anchor = root + .ancestors() + .last() + .filter(|path| path.has_root()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "authorized root must be an absolute normalized path", + ) + })?; + let relative = root.strip_prefix(root_anchor).map_err(|_| { + io::Error::new( + io::ErrorKind::InvalidInput, + "authorized root must be an absolute normalized path", + ) + })?; + let components = if relative.as_os_str().is_empty() { + Vec::new() + } else { + validated_relative_components(relative)? + }; + + let mut options = fs::OpenOptions::new(); + options + .access_mode(FILE_TRAVERSE | FILE_READ_ATTRIBUTES | SYNCHRONIZE) + .share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE) + .custom_flags(FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_OPEN_REPARSE_POINT); + let mut directory = options.open(root_anchor)?; + let metadata = directory.metadata()?; + if windows_metadata_is_reparse_point(&metadata) || !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "authorized root is not a regular directory", + )); + } + + let mut opened_path = root_anchor.to_path_buf(); + for component in components { + directory = windows_open_at(&directory, component, true)?; + let metadata = directory.metadata()?; + if windows_metadata_is_reparse_point(&metadata) || !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "authorized root ancestor is not a regular directory", + )); + } + opened_path.push(component); + after_opened_component(&opened_path); + } + if opened_path != root { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "authorized root must be an absolute normalized path", + )); + } + Ok(directory) +} + +#[cfg(windows)] +fn read_confined_utf8_with_hook( + root: &Path, + relative: &Path, + max_bytes: u64, + mut after_opened_component: impl FnMut(&Path), +) -> io::Result { + let components = validated_relative_components(relative)?; + let (file_name, ancestors) = components.split_last().unwrap(); + let mut directory = open_root(root, &mut after_opened_component)?; + + let mut opened_path = std::path::PathBuf::new(); + for ancestor in ancestors { + directory = windows_open_at(&directory, ancestor, true)?; + let metadata = directory.metadata()?; + if windows_metadata_is_reparse_point(&metadata) || !metadata.is_dir() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "source file ancestor is not a regular directory", + )); + } + opened_path.push(ancestor); + after_opened_component(&opened_path); + } + + let file = windows_open_at(&directory, file_name, false)?; + let metadata = file.metadata()?; + if windows_metadata_is_reparse_point(&metadata) || !metadata.is_file() { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "source file is not a regular file", + )); + } + read_opened_file(file, max_bytes) +} + +#[cfg(windows)] +fn windows_open_at( + directory: &fs::File, + name: &OsStr, + directory_only: bool, +) -> io::Result { + use ntapi::ntioapi::{ + FILE_DIRECTORY_FILE, FILE_OPEN, FILE_OPEN_REPARSE_POINT, FILE_SYNCHRONOUS_IO_NONALERT, + }; + use winapi::um::winnt::{FILE_GENERIC_READ, FILE_READ_ATTRIBUTES, FILE_TRAVERSE, SYNCHRONIZE}; + + let mut create_options = FILE_OPEN_REPARSE_POINT | FILE_SYNCHRONOUS_IO_NONALERT; + if directory_only { + create_options |= FILE_DIRECTORY_FILE; + } + let desired_access = if directory_only { + FILE_TRAVERSE | FILE_READ_ATTRIBUTES | SYNCHRONIZE + } else { + FILE_GENERIC_READ + }; + windows_open_at_with_options(directory, name, desired_access, FILE_OPEN, create_options) +} + +#[cfg(windows)] +fn windows_open_at_with_options( + directory: &fs::File, + name: &OsStr, + desired_access: u32, + create_disposition: u32, + create_options: u32, +) -> io::Result { + use ntapi::ntioapi::{NtCreateFile, IO_STATUS_BLOCK}; + use std::os::windows::ffi::OsStrExt; + use std::os::windows::io::{AsRawHandle, FromRawHandle}; + use winapi::shared::ntdef::{ + HANDLE, NT_SUCCESS, OBJECT_ATTRIBUTES, OBJ_CASE_INSENSITIVE, UNICODE_STRING, + }; + use winapi::um::winnt::{FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE}; + + let mut name: Vec = name.encode_wide().collect(); + let name_bytes = name + .len() + .checked_mul(std::mem::size_of::()) + .and_then(|length| u16::try_from(length).ok()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidInput, + "source file path component is too long", + ) + })?; + let mut unicode_name = UNICODE_STRING { + Length: name_bytes, + MaximumLength: name_bytes, + Buffer: name.as_mut_ptr(), + }; + let mut attributes = OBJECT_ATTRIBUTES { + Length: std::mem::size_of::() as u32, + RootDirectory: directory.as_raw_handle() as HANDLE, + ObjectName: &mut unicode_name, + Attributes: OBJ_CASE_INSENSITIVE, + SecurityDescriptor: std::ptr::null_mut(), + SecurityQualityOfService: std::ptr::null_mut(), + }; + let mut handle: HANDLE = std::ptr::null_mut(); + // SAFETY: IO_STATUS_BLOCK is initialized before the synchronous call. + let mut io_status: IO_STATUS_BLOCK = unsafe { std::mem::zeroed() }; + // SAFETY: all pointers reference initialized values for the duration of the synchronous call. + let status = unsafe { + NtCreateFile( + &mut handle, + desired_access, + &mut attributes, + &mut io_status, + std::ptr::null_mut(), + 0, + FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, + create_disposition, + create_options, + std::ptr::null_mut(), + 0, + ) + }; + if !NT_SUCCESS(status) { + return Err(windows_nt_status_error(status)); + } + // SAFETY: NtCreateFile returned a new owned handle on success. + Ok(unsafe { fs::File::from_raw_handle(handle.cast()) }) +} + +#[cfg(windows)] +fn windows_metadata_is_reparse_point(metadata: &fs::Metadata) -> bool { + use std::os::windows::fs::MetadataExt; + use winapi::um::winnt::FILE_ATTRIBUTE_REPARSE_POINT; + + metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 +} + +#[cfg(windows)] +fn windows_nt_status_error(status: winapi::shared::ntdef::NTSTATUS) -> io::Error { + // SAFETY: RtlNtStatusToDosError accepts every NTSTATUS value. + let error = unsafe { ntapi::ntrtl::RtlNtStatusToDosError(status) }; + io::Error::from_raw_os_error(error as i32) +} + +#[cfg(not(any(unix, windows)))] +fn read_confined_utf8_with_hook( + _root: &Path, + relative: &Path, + _max_bytes: u64, + _after_opened_component: impl FnMut(&Path), +) -> io::Result { + validated_relative_components(relative)?; + Err(io::Error::new( + io::ErrorKind::Unsupported, + "secure source reads are not supported on this platform", + )) +} + +#[cfg(test)] +pub(super) fn read_confined_utf8_for_test( + root: &Path, + relative: &Path, + max_bytes: u64, + after_opened_component: impl FnMut(&Path), +) -> io::Result { + read_confined_utf8_with_hook(root, relative, max_bytes, after_opened_component) +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 63cbd7f5a..f2d62d7c4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -450,6 +450,9 @@ pub fn run() { commands::agents::read_import_agent_image, commands::agents::read_agent_source_file, commands::agents::repair_bundled_agent, + commands::source_transfer::export_skill_source, + commands::source_transfer::import_skill_source, + commands::source_transfer::import_agent_source, #[cfg(feature = "block-builderbot")] commands::auth::auth_status, #[cfg(feature = "block-builderbot")] diff --git a/src-tauri/src/services/goose_config.rs b/src-tauri/src/services/goose_config.rs index 60a7f1b98..914f4cb44 100644 --- a/src-tauri/src/services/goose_config.rs +++ b/src-tauri/src/services/goose_config.rs @@ -19,8 +19,16 @@ pub(crate) struct AdditionalConfigFiles { /// Resolve the upstream goose config file path. Matches /// `crates/goose/src/config/paths.rs::Paths::config_dir`. pub(crate) fn config_path() -> Result { - if let Some(root) = validated_path_root(env::var_os(GOOSE_PATH_ROOT_ENV)) { - return Ok(root.join("config").join(CONFIG_FILE_NAME)); + Ok(config_dir()?.join(CONFIG_FILE_NAME)) +} + +pub(crate) fn path_root() -> Option { + validated_path_root(env::var_os(GOOSE_PATH_ROOT_ENV)) +} + +pub(crate) fn config_dir() -> Result { + if let Some(root) = path_root() { + return Ok(root.join("config")); } let strategy = choose_app_strategy(AppStrategyArgs { @@ -30,7 +38,26 @@ pub(crate) fn config_path() -> Result { }) .map_err(|err| format!("Failed to resolve goose config directory: {err}"))?; - Ok(strategy.config_dir().join(CONFIG_FILE_NAME)) + Ok(strategy.config_dir()) +} + +pub(crate) fn agents_root() -> Result { + if let Some(root) = agents_root_from_path_root(path_root()) { + return Ok(root); + } + + let strategy = choose_app_strategy(AppStrategyArgs { + top_level_domain: "Block".to_string(), + author: "Block".to_string(), + app_name: "goose".to_string(), + }) + .map_err(|err| format!("Failed to resolve goose agents directory: {err}"))?; + + Ok(strategy.home_dir().join(".agents")) +} + +fn agents_root_from_path_root(path_root: Option) -> Option { + path_root.map(|root| root.join(".agents")) } fn validated_path_root(value: Option) -> Option { @@ -111,4 +138,14 @@ mod tests { Some(absolute) ); } + + #[test] + fn agents_root_uses_goose_path_root() { + let root = PathBuf::from("/tmp/goose-root"); + + assert_eq!( + agents_root_from_path_root(Some(root.clone())), + Some(root.join(".agents")) + ); + } } diff --git a/src/features/skills/api/skills.test.ts b/src/features/skills/api/skills.test.ts index 9b7a818e0..d0a1e520f 100644 --- a/src/features/skills/api/skills.test.ts +++ b/src/features/skills/api/skills.test.ts @@ -5,7 +5,6 @@ const mockGooseSourcesList = vi.fn(); const mockGooseSourcesCreate = vi.fn(); const mockGooseSourcesDelete = vi.fn(); const mockGooseSourcesUpdate = vi.fn(); -const mockGooseSourcesImport = vi.fn(); const mockInvoke = vi.fn(); vi.mock("@tauri-apps/api/core", () => ({ @@ -23,8 +22,6 @@ vi.mock("@/shared/api/acpConnection", () => ({ mockGooseSourcesDelete(...args), GooseUnstableSourcesUpdate: (...args: unknown[]) => mockGooseSourcesUpdate(...args), - GooseUnstableSourcesImport: (...args: unknown[]) => - mockGooseSourcesImport(...args), }, }), })); @@ -180,7 +177,7 @@ describe("skill mutation events", () => { }, }); mockGooseSourcesDelete.mockResolvedValue({}); - mockGooseSourcesImport.mockResolvedValue({ + mockInvoke.mockResolvedValue({ sources: [ { type: "skill", @@ -210,11 +207,29 @@ describe("skill mutation events", () => { await importSkills([123, 125], "IMPORTED.SKILL.JSON"); expect(listener).toHaveBeenCalledTimes(3); + expect(mockInvoke).toHaveBeenCalledWith("import_skill_source", { + data: "{}", + }); } finally { window.removeEventListener(SKILLS_CHANGED_EVENT, listener); } }); + it("exports skills through the dedicated native command", async () => { + mockInvoke.mockResolvedValue({ + json: '{"version":1,"type":"skill"}', + filename: "test-writer.skill.json", + }); + const { exportSkill } = await import("./skills"); + + const result = await exportSkill("/Users/test/.agents/skills/test-writer"); + + expect(mockInvoke).toHaveBeenCalledWith("export_skill_source", { + path: "/Users/test/.agents/skills/test-writer", + }); + expect(result.filename).toBe("test-writer.skill.json"); + }); + it("does not emit the skills changed event when a mutation fails", async () => { mockGooseSourcesCreate.mockRejectedValue(new Error("permission denied")); const listener = vi.fn(); diff --git a/src/features/skills/api/skills.ts b/src/features/skills/api/skills.ts index b0a480c34..fcd6aa384 100644 --- a/src/features/skills/api/skills.ts +++ b/src/features/skills/api/skills.ts @@ -517,11 +517,10 @@ export async function updateSkill( export async function exportSkill( path: string, ): Promise<{ json: string; filename: string }> { - const client = await getClient(); - const response = await client.goose.GooseUnstableSourcesExport({ - type: SKILL_SOURCE_TYPE, - path, - }); + const response = await invoke<{ json: string; filename: string }>( + "export_skill_source", + { path }, + ); return { json: response.json, filename: response.filename }; } @@ -541,11 +540,10 @@ export async function importSkills( } const data = new TextDecoder().decode(new Uint8Array(fileBytes)); - const client = await getClient(); - const response = await client.goose.GooseUnstableSourcesImport({ - data, - target: { scope: "global" }, - }); + const response = await invoke<{ sources: SourceEntry[] }>( + "import_skill_source", + { data }, + ); emitSkillsChanged(); diff --git a/src/shared/api/__tests__/agents.test.ts b/src/shared/api/__tests__/agents.test.ts index 1d47836c2..99ceaff0d 100644 --- a/src/shared/api/__tests__/agents.test.ts +++ b/src/shared/api/__tests__/agents.test.ts @@ -7,7 +7,6 @@ const mockGooseSourcesCreate = vi.fn(); const mockGooseSourcesUpdate = vi.fn(); const mockGooseSourcesDelete = vi.fn(); const mockGooseSourcesExport = vi.fn(); -const mockGooseSourcesImport = vi.fn(); const appAvatarRef = "app-avatar:gloopy-1"; vi.mock("@tauri-apps/api/core", () => ({ @@ -27,8 +26,6 @@ vi.mock("@/shared/api/acpConnection", () => ({ mockGooseSourcesDelete(...args), GooseUnstableSourcesExport: (...args: unknown[]) => mockGooseSourcesExport(...args), - GooseUnstableSourcesImport: (...args: unknown[]) => - mockGooseSourcesImport(...args), }, }), })); @@ -74,7 +71,6 @@ describe("agents API", () => { mockGooseSourcesUpdate.mockReset(); mockGooseSourcesDelete.mockReset(); mockGooseSourcesExport.mockReset(); - mockGooseSourcesImport.mockReset(); mockedInvoke.mockReset(); }); @@ -1037,7 +1033,7 @@ describe("agents API", () => { avatar: "https://example.test/scout.png", }, }); - expect(mockGooseSourcesImport).not.toHaveBeenCalled(); + expect(mockedInvoke).not.toHaveBeenCalled(); expect(result).toHaveLength(1); }); @@ -1086,7 +1082,7 @@ Research carefully. }, }, }); - expect(mockGooseSourcesImport).not.toHaveBeenCalled(); + expect(mockedInvoke).not.toHaveBeenCalled(); expect(result).toHaveLength(1); }); @@ -1146,7 +1142,7 @@ Research carefully. }, }, }); - expect(mockGooseSourcesImport).not.toHaveBeenCalled(); + expect(mockedInvoke).not.toHaveBeenCalled(); }); it("imports app avatar refs from legacy persona JSON", async () => { @@ -1301,8 +1297,8 @@ Research carefully. }); }); - it("imports native agent JSON through ACP source import", async () => { - mockGooseSourcesImport.mockResolvedValue({ sources: [agentSource] }); + it("imports native agent JSON through the dedicated native command", async () => { + mockedInvoke.mockResolvedValue({ sources: [agentSource] }); const { importPersonas } = await import("../agents"); const raw = JSON.stringify({ @@ -1315,15 +1311,15 @@ Research carefully. await importPersonas(raw, "scout.agent.json"); - expect(mockGooseSourcesImport).toHaveBeenCalledWith({ + expect(mockedInvoke).toHaveBeenCalledWith("import_agent_source", { data: raw, - target: { scope: "global" }, }); expect(mockGooseSourcesCreate).not.toHaveBeenCalled(); + expect(mockGooseSourcesUpdate).not.toHaveBeenCalled(); }); it("drops legacy trait metadata from native agent imports", async () => { - mockGooseSourcesImport.mockResolvedValue({ sources: [agentSource] }); + mockedInvoke.mockResolvedValue({ sources: [agentSource] }); const { importPersonas } = await import("../agents"); await importPersonas( JSON.stringify({ @@ -1348,7 +1344,7 @@ Research carefully. "scout.agent.json", ); - const importRequest = mockGooseSourcesImport.mock.calls[0]?.[0] as { + const importRequest = mockedInvoke.mock.calls[0]?.[1] as { data: string; }; expect(JSON.parse(importRequest.data)).toMatchObject({ @@ -1357,23 +1353,16 @@ Research carefully. }); }); - it("preserves native agent JSON app avatar refs when ACP import omits them", async () => { + it("preserves native agent JSON app avatar refs without a follow-up update", async () => { const importedSource = { ...agentSource, path: "/Users/test/.agents/agents/scout-imported.md", - properties: { - color: "blue", - }, - }; - const updatedSource = { - ...importedSource, properties: { color: "blue", avatar: appAvatarRef, }, }; - mockGooseSourcesImport.mockResolvedValue({ sources: [importedSource] }); - mockGooseSourcesUpdate.mockResolvedValue({ source: updatedSource }); + mockedInvoke.mockResolvedValue({ sources: [importedSource] }); const { importPersonas } = await import("../agents"); const raw = JSON.stringify({ @@ -1389,7 +1378,7 @@ Research carefully. }); const [persona] = await importPersonas(raw, "scout.agent.json"); - const importRequest = mockGooseSourcesImport.mock.calls[0]?.[0] as { + const importRequest = mockedInvoke.mock.calls[0]?.[1] as { data: string; }; @@ -1399,61 +1388,13 @@ Research carefully. avatar: appAvatarRef, }, }); - expect(mockGooseSourcesUpdate).toHaveBeenCalledWith({ - type: "agent", - path: importedSource.path, - name: "Scout", - description: "Agent", - content: "Research carefully.", - properties: { - color: "blue", - avatar: appAvatarRef, - }, - }); + expect(mockGooseSourcesUpdate).not.toHaveBeenCalled(); expect(persona.avatar).toBe(appAvatarRef); expect(persona.sourceProperties?.avatar).toBe(appAvatarRef); }); - it("keeps native agent JSON imports successful when avatar repair fails", async () => { - const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); - const importedSource = { - ...agentSource, - path: "/Users/test/.agents/agents/scout-imported.md", - properties: { - color: "blue", - }, - }; - mockGooseSourcesImport.mockResolvedValue({ sources: [importedSource] }); - mockGooseSourcesUpdate.mockRejectedValue(new Error("update failed")); - - const { importPersonas } = await import("../agents"); - const raw = JSON.stringify({ - version: 1, - type: "agent", - name: "Scout", - description: "Agent", - content: "Research carefully.", - properties: { - avatar: appAvatarRef, - }, - }); - - const [persona] = await importPersonas(raw, "scout.agent.json"); - - expect(persona.avatar).toBe(appAvatarRef); - expect(persona.sourceProperties).toEqual({ - color: "blue", - avatar: appAvatarRef, - }); - expect(warnSpy).toHaveBeenCalledWith( - "Failed to preserve imported agent avatar:", - expect.any(Error), - ); - warnSpy.mockRestore(); - }); - - it("strips unsafe native agent JSON avatar values before ACP import", async () => { - mockGooseSourcesImport.mockResolvedValue({ + it("strips unsafe native agent JSON avatar values before native import", async () => { + mockedInvoke.mockResolvedValue({ sources: [ { ...agentSource, @@ -1483,7 +1424,7 @@ Research carefully. }); const [persona] = await importPersonas(raw, "scout.agent.json"); - const importRequest = mockGooseSourcesImport.mock.calls[0]?.[0] as { + const importRequest = mockedInvoke.mock.calls[0]?.[1] as { data: string; }; const importedPayload = JSON.parse(importRequest.data); @@ -1515,7 +1456,7 @@ Research carefully. "Unsupported persona format version 2", ); expect(mockGooseSourcesCreate).not.toHaveBeenCalled(); - expect(mockGooseSourcesImport).not.toHaveBeenCalled(); + expect(mockedInvoke).not.toHaveBeenCalled(); }); it("validates malformed legacy content loaded from a .json file", async () => { @@ -1525,7 +1466,7 @@ Research carefully. "Unsupported persona format version undefined", ); expect(mockGooseSourcesCreate).not.toHaveBeenCalled(); - expect(mockGooseSourcesImport).not.toHaveBeenCalled(); + expect(mockedInvoke).not.toHaveBeenCalled(); }); it("keeps native import file reads on the Tauri command", async () => { diff --git a/src/shared/api/agents.ts b/src/shared/api/agents.ts index 7ad86a4be..6738c839c 100644 --- a/src/shared/api/agents.ts +++ b/src/shared/api/agents.ts @@ -481,7 +481,6 @@ function legacyPersonaToCreateRequest(parsed: Record) { function sanitizedNativeAgentImport(parsed: Record): { data: string; - avatar?: string; } { const sanitized = { ...parsed }; const properties = propertyToRecord(parsed.properties); @@ -531,7 +530,6 @@ function sanitizedNativeAgentImport(parsed: Record): { return { data: JSON.stringify(sanitized), - avatar, }; } @@ -704,40 +702,6 @@ function requireAgentSource(source: SourceEntry): AgentSourceEntry { return source; } -async function preserveImportedAvatar( - source: AgentSourceEntry, - avatar: string | undefined, -): Promise { - if (!avatar || normalizeAvatarUrl(source.properties?.avatar) === avatar) { - return source; - } - - const properties = { - ...(source.properties ?? {}), - avatar, - }; - - try { - const client = await getClient(); - const response = await client.goose.GooseUnstableSourcesUpdate({ - type: AGENT_SOURCE_TYPE, - path: source.path, - name: source.name, - description: source.description, - content: source.content, - properties, - }); - - return requireAgentSource(response.source); - } catch (error) { - console.warn("Failed to preserve imported agent avatar:", error); - return { - ...source, - properties, - }; - } -} - async function findPersonaSource(path: string): Promise { const source = (await listPersonaSources()).find( (source) => source.path === path, @@ -1062,9 +1026,8 @@ export async function importPersonas( ); } - const client = await getClient(); - if (isPersonaMarkdownFile(fileName)) { + const client = await getClient(); const response = await client.goose.GooseUnstableSourcesCreate( personaMarkdownToCreateRequest(fileContents), ); @@ -1080,18 +1043,14 @@ export async function importPersonas( if (parsed.type === AGENT_SOURCE_TYPE) { const nativeImport = sanitizedNativeAgentImport(parsed); - const response = await client.goose.GooseUnstableSourcesImport({ - data: nativeImport.data, - target: { scope: "global" }, - }); - const sources = await Promise.all( - response.sources - .filter(isAgentSource) - .map((source) => preserveImportedAvatar(source, nativeImport.avatar)), + const response = await invoke<{ sources: SourceEntry[] }>( + "import_agent_source", + { data: nativeImport.data }, ); - return sources.map(agentSourceToPersona); + return response.sources.filter(isAgentSource).map(agentSourceToPersona); } + const client = await getClient(); const response = await client.goose.GooseUnstableSourcesCreate( legacyPersonaToCreateRequest(parsed), );