diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 2b57753f..d55eefd0 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -42,7 +42,7 @@ const overrides = new Map([ ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], ["src/shared/api/relayClientSession.ts", 790], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions - ["src-tauri/src/lib.rs", 550], // sprout-media:// proxy now forwards Range headers + propagates Content-Range/Accept-Ranges for video seeking + ["src-tauri/src/lib.rs", 560], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() ["src-tauri/src/commands/media.rs", 720], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests ["src-tauri/src/commands/agents.rs", 849], // remote agent lifecycle routing (local + provider branches) + scope enforcement; rustfmt adds line breaks around long tuple/closure blocks ["src-tauri/src/managed_agents/runtime.rs", 650], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index b07911cb..37d3987d 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -10,8 +10,9 @@ mod util; use app_state::{build_app_state, resolve_persisted_identity, AppState}; use commands::*; use managed_agents::{ - find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, save_managed_agents, - start_managed_agent_process, sync_managed_agent_processes, BackendKind, ManagedAgentProcess, + ensure_nest, find_managed_agent_mut, kill_stale_tracked_processes, load_managed_agents, + save_managed_agents, start_managed_agent_process, sync_managed_agent_processes, BackendKind, + ManagedAgentProcess, }; use std::sync::{ atomic::{AtomicBool, Ordering}, @@ -387,6 +388,13 @@ pub fn run() { resolve_persisted_identity(&app_handle, &state) .map_err(|e| -> Box { e.into() })?; + // Create the Sprout nest (~/.sprout) before agents are restored, + // so default_agent_workdir() resolves to the nest directory. + // Non-fatal: agents fall back to $HOME if nest creation fails. + if let Err(error) = ensure_nest() { + eprintln!("sprout-desktop: failed to create nest: {error}"); + } + // Keep launch-time agent restoration off the synchronous setup path // so the frontend can mount and reveal the window promptly. tauri::async_runtime::spawn_blocking(move || { diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index f62cc217..15a52e1f 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -1,5 +1,6 @@ mod backend; mod discovery; +mod nest; mod persona_avatars; mod persona_card; mod personas; @@ -10,6 +11,7 @@ mod types; pub use backend::*; pub use discovery::*; +pub use nest::*; pub use persona_card::*; pub use personas::*; pub use runtime::*; @@ -17,16 +19,34 @@ pub use storage::*; pub use teams::*; pub use types::*; -/// Returns the user's home directory if it can be resolved and exists. +/// Returns the Sprout nest directory (`~/.sprout`) if it exists as a real +/// directory (not a symlink), falling back to the user's home directory. +/// /// Used as the default working directory for spawned agent processes. +/// `ensure_nest()` must be called during app setup before this is first +/// invoked, so that `~/.sprout` exists and gets cached. /// -/// Cached for the process lifetime — home directory doesn't change at runtime. +/// Cached for the process lifetime via `OnceLock`. /// Returns `None` in sandboxed/containerized environments where `$HOME` is /// unset or points to a non-existent path; callers fall back to inheriting /// the parent's CWD. pub fn default_agent_workdir() -> Option { use std::sync::OnceLock; - static HOME: OnceLock> = OnceLock::new(); - HOME.get_or_init(|| dirs::home_dir().filter(|p| p.is_dir())) + static WORKDIR: OnceLock> = OnceLock::new(); + WORKDIR + .get_or_init(|| { + // Prefer ~/.sprout if it exists (created by ensure_nest()). + // Reject symlinks to prevent redirect attacks — is_dir() + // follows symlinks, so check symlink_metadata() first. + // Fall back to $HOME for resilience. + nest_dir() + .filter(|p| is_real_dir(p)) + .or_else(|| dirs::home_dir().filter(|p| p.is_dir())) + }) .clone() } + +/// Returns `true` if `path` is a real directory (not a symlink). +fn is_real_dir(path: &std::path::Path) -> bool { + path.symlink_metadata().map(|m| m.is_dir()).unwrap_or(false) +} diff --git a/desktop/src-tauri/src/managed_agents/nest.rs b/desktop/src-tauri/src/managed_agents/nest.rs new file mode 100644 index 00000000..b9e9f119 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest.rs @@ -0,0 +1,233 @@ +//! Sprout Nest — persistent agent workspace at `~/.sprout`. +//! +//! Creates a shared knowledge directory on first launch so every +//! Sprout-spawned agent starts with orientation (AGENTS.md) and a +//! place to accumulate research, plans, and logs across sessions. +//! +//! Idempotent: existing files and directories are never overwritten. + +use std::fs; +use std::path::{Path, PathBuf}; + +/// Subdirectories created inside the nest. +const NEST_DIRS: &[&str] = &[ + "GUIDES", + "RESEARCH", + "PLANS", + "WORK_LOGS", + "REPOS", + "OUTBOX", + ".scratch", +]; + +/// Default AGENTS.md content written on first init. +/// Fully static — no runtime interpolation, no secrets, no user paths. +const AGENTS_MD: &str = include_str!("nest_agents.md"); + +/// Returns the nest root path (`~/.sprout`), or `None` if the home +/// directory cannot be resolved. +pub fn nest_dir() -> Option { + dirs::home_dir().map(|h| h.join(".sprout")) +} + +/// Creates the Sprout nest at `~/.sprout` if it doesn't already exist. +/// +/// Delegates to [`ensure_nest_at`] with the resolved nest directory. +/// Returns an error string if the home directory cannot be resolved. +pub fn ensure_nest() -> Result<(), String> { + let root = nest_dir().ok_or("cannot resolve home directory for nest")?; + ensure_nest_at(&root) +} + +/// Creates a Sprout nest at the given `root` path. +/// +/// - Creates the root directory and all subdirectories. +/// - Writes `AGENTS.md` only if it doesn't already exist. +/// - Sets 700 permissions on the root and all subdirectories (Unix). +/// +/// Idempotent: safe to call on every launch. Existing files are never +/// overwritten — users can freely edit AGENTS.md and it will persist. +/// +/// Rejects symlinks at the root path to prevent redirect attacks. +/// +/// Errors are returned as strings for Tauri compatibility; callers +/// should log and continue rather than aborting app startup. +pub fn ensure_nest_at(root: &Path) -> Result<(), String> { + // Reject symlinks — we want a real directory, not a redirect. + // Platform-independent: symlink_metadata works on all OS. + if root + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false) + { + return Err(format!( + "{} is a symlink; refusing to use as nest root", + root.display() + )); + } + + // Create root and all subdirectories. create_dir_all is idempotent — + // it succeeds silently if the directory already exists. + fs::create_dir_all(root).map_err(|e| format!("create {}: {e}", root.display()))?; + + for dir in NEST_DIRS { + let path = root.join(dir); + fs::create_dir_all(&path).map_err(|e| format!("create {}: {e}", path.display()))?; + } + + // Write AGENTS.md only if it doesn't already exist. + // Uses create_new (O_CREAT|O_EXCL) to atomically check-and-create, + // closing the TOCTOU gap that exists() + write() would leave open. + // Also guarantees we never clobber a user-edited file. + let agents_md = root.join("AGENTS.md"); + match fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&agents_md) + { + Ok(mut file) => { + use std::io::Write; + file.write_all(AGENTS_MD.as_bytes()) + .map_err(|e| format!("write {}: {e}", agents_md.display()))?; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + // File already exists — leave it alone (idempotent). + } + Err(e) => { + return Err(format!("create {}: {e}", agents_md.display())); + } + } + + // Set owner-only permissions on root and all subdirectories. + // Skip any path that is a symlink — chmod would affect the target. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = fs::Permissions::from_mode(0o700); + fs::set_permissions(root, perms.clone()) + .map_err(|e| format!("set permissions on {}: {e}", root.display()))?; + for dir in NEST_DIRS { + let path = root.join(dir); + let is_symlink = path + .symlink_metadata() + .map(|m| m.file_type().is_symlink()) + .unwrap_or(false); + if !is_symlink { + fs::set_permissions(&path, perms.clone()) + .map_err(|e| format!("set permissions on {}: {e}", path.display()))?; + } + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nest_dir_is_under_home() { + if let Some(dir) = nest_dir() { + assert!(dir.ends_with(".sprout")); + } + } + + #[test] + fn ensure_nest_creates_all_dirs_and_agents_md() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".sprout"); + + ensure_nest_at(&root).unwrap(); + + // All subdirectories exist. + for dir in NEST_DIRS { + assert!(root.join(dir).is_dir(), "{dir}/ should exist"); + } + + // AGENTS.md was written with default content. + let content = fs::read_to_string(root.join("AGENTS.md")).unwrap(); + assert_eq!(content, AGENTS_MD); + + // Permissions are 700 on Unix for root and all subdirs. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = fs::metadata(&root).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "root should be 700"); + for dir in NEST_DIRS { + let mode = fs::metadata(root.join(dir)).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "{dir}/ should be 700"); + } + } + } + + #[test] + fn ensure_nest_is_idempotent_and_preserves_custom_content() { + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".sprout"); + + // First call creates everything. + ensure_nest_at(&root).unwrap(); + + // User customizes AGENTS.md. + let agents = root.join("AGENTS.md"); + fs::write(&agents, "my custom instructions").unwrap(); + + // Second call succeeds and does not overwrite. + ensure_nest_at(&root).unwrap(); + + assert_eq!( + fs::read_to_string(&agents).unwrap(), + "my custom instructions" + ); + + // All dirs still exist. + for dir in NEST_DIRS { + assert!(root.join(dir).is_dir(), "{dir}/ should still exist"); + } + } + + #[cfg(unix)] + #[test] + fn ensure_nest_rejects_symlink_root() { + let tmp = tempfile::tempdir().unwrap(); + let target = tmp.path().join("real_dir"); + fs::create_dir(&target).unwrap(); + let link = tmp.path().join(".sprout"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let result = ensure_nest_at(&link); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("symlink")); + } + + #[cfg(unix)] + #[test] + fn ensure_nest_skips_permissions_on_symlinked_child() { + use std::os::unix::fs::PermissionsExt; + + let tmp = tempfile::tempdir().unwrap(); + let root = tmp.path().join(".sprout"); + + // First call creates the real nest. + ensure_nest_at(&root).unwrap(); + + // Replace REPOS/ with a symlink to an external directory. + let external = tmp.path().join("external"); + fs::create_dir(&external).unwrap(); + fs::set_permissions(&external, fs::Permissions::from_mode(0o755)).unwrap(); + fs::remove_dir(&root.join("REPOS")).unwrap(); + std::os::unix::fs::symlink(&external, &root.join("REPOS")).unwrap(); + + // Second call should succeed — it skips chmod on the symlinked child. + ensure_nest_at(&root).unwrap(); + + // The external directory's permissions should be unchanged (755, not 700). + let mode = fs::metadata(&external).unwrap().permissions().mode() & 0o777; + assert_eq!( + mode, 0o755, + "symlinked child's target should not be chmod'd" + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/nest_agents.md b/desktop/src-tauri/src/managed_agents/nest_agents.md new file mode 100644 index 00000000..3257c8d0 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/nest_agents.md @@ -0,0 +1,71 @@ +# Sprout Nest + +Your persistent workspace. Created once by the Sprout desktop app — never overwritten. Edit freely. + +## Directory Layout + +| Dir | Purpose | +|-----|---------| +| `GUIDES/` | Actionable runbooks synthesized from research | +| `PLANS/` | Planning documents for work in progress | +| `RESEARCH/` | Findings, notes, and reference material | +| `WORK_LOGS/` | Session logs — what was tried, learned, decided | +| `OUTBOX/` | Shareable docs for external readers (no frontmatter) | +| `REPOS/` | Cloned repositories (clone freely here for exploration) | +| `.scratch/` | Temporary working files — treat as disposable between sessions | + +Filenames: `ALL_CAPS_WITH_UNDERSCORES.md` (e.g., `OAUTH_FLOW_NOTES.md`). + +## Communicating via Sprout + +You have MCP tools for channels. Use them. + +**Read messages:** +- `get_messages(channel_id, limit=50)` — recent history (max 200) +- `get_thread(channel_id, event_id)` — drill into a thread +- `get_feed()` — personalized: your mentions, needs-action items + +**Post messages:** +- `send_message(channel_id, content)` — new message +- `send_message(channel_id, content, parent_event_id)` — threaded reply + +**Poll for new messages** (no push — poll with sleep): +- Call `get_messages(channel_id, since=)` where the value is the `created_at` timestamp of the last message you saw +- When `since` is set without `before`, results are **oldest-first** (chronological) +- Sleep 10–30 seconds between polls + +**Search:** +- `search(q="your query")` — searches across all channels + +## Recovering Context on Startup + +1. Call `get_feed()` — surface mentions and items needing your action +2. Call `get_messages` on your assigned channel(s) to read recent history +3. Check `RESEARCH/`, `PLANS/`, `GUIDES/` before researching from scratch + +## Knowledge File Conventions + +Files in `GUIDES/`, `PLANS/`, `RESEARCH/`, `WORK_LOGS/` should include YAML frontmatter: + +```yaml +--- +title: "Always Quoted Title" +tags: [lowercase-hyphenated] +status: active +created: 2026-01-15 +--- +``` + +**Status values:** `active` | `superseded` | `stale` | `draft` + +> ⚠️ Title **must** be quoted — unquoted colons can break YAML parsing. + +## Core Guidelines + +- **Local first** — check `RESEARCH/`, `GUIDES/`, `PLANS/` before external searches +- **Write findings down** — if you research something, save it to `RESEARCH/` +- **Cite sources** — no claim without a path, link, or reference +- **Don't overwrite** — append or create new files; don't silently clobber existing work +- **`.scratch/` is disposable** — don't rely on it across sessions +- **Never push without approval** — do not `git push` to any remote +- **Stay on task** — only stage files relevant to your current work