improve goose2 agent management flows (#8737)
Signed-off-by: tulsi <tulsi@block.xyz>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use crate::services::personas::PersonaStore;
|
||||
use crate::types::agents::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use tauri::State;
|
||||
|
||||
#[tauri::command]
|
||||
@@ -59,6 +60,57 @@ pub fn get_avatars_dir() -> String {
|
||||
PersonaStore::avatars_dir().to_string_lossy().to_string()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct ImportFileReadResult {
|
||||
pub file_bytes: Vec<u8>,
|
||||
pub file_name: String,
|
||||
}
|
||||
|
||||
fn validate_import_persona_path(source_path: &str) -> Result<PathBuf, String> {
|
||||
let path = PathBuf::from(source_path);
|
||||
|
||||
if path.as_os_str().is_empty() {
|
||||
return Err("Selected file path is empty".to_string());
|
||||
}
|
||||
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|ext| ext.to_str())
|
||||
.ok_or_else(|| "Unsupported file type. Expected a .json file.".to_string())?;
|
||||
if !extension.eq_ignore_ascii_case("json") {
|
||||
return Err("Unsupported file type. Expected a .json file.".to_string());
|
||||
}
|
||||
|
||||
let metadata = std::fs::metadata(&path)
|
||||
.map_err(|err| format!("Failed to access import file '{}': {}", path.display(), err))?;
|
||||
if !metadata.is_file() {
|
||||
return Err(format!(
|
||||
"Selected import path '{}' is not a file",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn read_import_persona_file(source_path: String) -> Result<ImportFileReadResult, String> {
|
||||
let path = validate_import_persona_path(&source_path)?;
|
||||
let file_name = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.ok_or_else(|| "Selected file is missing a valid filename".to_string())?
|
||||
.to_string();
|
||||
let file_bytes = std::fs::read(&path)
|
||||
.map_err(|err| format!("Failed to read import file '{}': {}", path.display(), err))?;
|
||||
|
||||
Ok(ImportFileReadResult {
|
||||
file_bytes,
|
||||
file_name,
|
||||
})
|
||||
}
|
||||
|
||||
// --- Sprout-compatible persona import/export ---
|
||||
|
||||
/// Sprout-compatible persona export format (version 1, camelCase keys).
|
||||
@@ -208,3 +260,41 @@ pub fn import_personas(
|
||||
let persona = store.create(request)?;
|
||||
Ok(vec![persona])
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::validate_import_persona_path;
|
||||
|
||||
#[test]
|
||||
fn validate_import_persona_path_rejects_non_json_files() {
|
||||
let path = std::env::temp_dir().join("persona-import.txt");
|
||||
std::fs::write(&path, b"{}").unwrap();
|
||||
|
||||
let result = validate_import_persona_path(path.to_str().unwrap());
|
||||
|
||||
assert!(result.is_err());
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_import_persona_path_rejects_directories() {
|
||||
let dir = std::env::temp_dir().join(format!("persona-import-dir-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
let result = validate_import_persona_path(dir.to_str().unwrap());
|
||||
|
||||
assert!(result.is_err());
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_import_persona_path_accepts_json_files() {
|
||||
let path = std::env::temp_dir().join(format!("persona-import-{}.json", std::process::id()));
|
||||
std::fs::write(&path, b"{}").unwrap();
|
||||
|
||||
let validated = validate_import_persona_path(path.to_str().unwrap()).unwrap();
|
||||
|
||||
assert_eq!(validated, path);
|
||||
let _ = std::fs::remove_file(validated);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ pub fn run() {
|
||||
commands::agents::refresh_personas,
|
||||
commands::agents::export_persona,
|
||||
commands::agents::import_personas,
|
||||
commands::agents::read_import_persona_file,
|
||||
commands::agents::save_persona_avatar,
|
||||
commands::agents::save_persona_avatar_bytes,
|
||||
commands::agents::get_avatars_dir,
|
||||
|
||||
@@ -3,7 +3,7 @@ use crate::types::agents::{
|
||||
};
|
||||
use log::warn;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
use std::sync::Mutex;
|
||||
|
||||
pub struct PersonaStore {
|
||||
@@ -197,6 +197,26 @@ impl PersonaStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn markdown_persona_path(id: &str) -> Result<PathBuf, String> {
|
||||
let slug = id
|
||||
.strip_prefix("md-")
|
||||
.ok_or_else(|| format!("Persona '{}' is not a file-backed persona", id))?;
|
||||
Self::validate_markdown_persona_slug(slug)?;
|
||||
Ok(Self::agents_dir().join(format!("{}.md", slug)))
|
||||
}
|
||||
|
||||
fn validate_markdown_persona_slug(slug: &str) -> Result<(), String> {
|
||||
if slug.chars().any(|c| matches!(c, '/' | '\\')) {
|
||||
return Err(format!("Persona '{}' has an invalid file-backed ID", slug));
|
||||
}
|
||||
|
||||
let mut components = Path::new(slug).components();
|
||||
match (components.next(), components.next()) {
|
||||
(Some(Component::Normal(_)), None) => Ok(()),
|
||||
_ => Err(format!("Persona '{}' has an invalid file-backed ID", slug)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-scan markdown personas and update the in-memory list.
|
||||
/// Returns the full updated persona list.
|
||||
pub fn refresh_markdown(&self) -> Vec<Persona> {
|
||||
@@ -298,13 +318,29 @@ impl PersonaStore {
|
||||
let persona = personas
|
||||
.iter()
|
||||
.find(|p| p.id == id)
|
||||
.cloned()
|
||||
.ok_or_else(|| format!("Persona '{}' not found", id))?;
|
||||
|
||||
if persona.is_builtin {
|
||||
return Err("Cannot delete a built-in persona".to_string());
|
||||
}
|
||||
if persona.is_from_disk {
|
||||
return Err("Cannot delete a markdown persona — delete the file directly".to_string());
|
||||
let path = Self::markdown_persona_path(id)?;
|
||||
match std::fs::remove_file(&path) {
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
|
||||
Err(err) => {
|
||||
return Err(format!(
|
||||
"Failed to delete file-backed persona '{}': {}",
|
||||
path.display(),
|
||||
err
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
personas.retain(|p| p.id != id);
|
||||
self.save_to_disk(&personas);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Clean up local avatar file if present
|
||||
@@ -395,3 +431,27 @@ impl PersonaStore {
|
||||
let _ = std::fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::PersonaStore;
|
||||
|
||||
#[test]
|
||||
fn markdown_persona_path_rejects_parent_segments() {
|
||||
assert!(PersonaStore::markdown_persona_path("md-../secret").is_err());
|
||||
assert!(PersonaStore::markdown_persona_path("md-..").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_persona_path_rejects_path_separators() {
|
||||
assert!(PersonaStore::markdown_persona_path("md-nested/slug").is_err());
|
||||
assert!(PersonaStore::markdown_persona_path(r"md-nested\slug").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_persona_path_accepts_normal_slug() {
|
||||
let path = PersonaStore::markdown_persona_path("md-scout").unwrap();
|
||||
let file_name = path.file_name().and_then(|name| name.to_str());
|
||||
assert_eq!(file_name, Some("scout.md"));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user