Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 10 additions & 2 deletions desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -387,6 +388,13 @@ pub fn run() {
resolve_persisted_identity(&app_handle, &state)
.map_err(|e| -> Box<dyn std::error::Error> { 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 || {
Expand Down
28 changes: 24 additions & 4 deletions desktop/src-tauri/src/managed_agents/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod backend;
mod discovery;
mod nest;
mod persona_avatars;
mod persona_card;
mod personas;
Expand All @@ -10,23 +11,42 @@ mod types;

pub use backend::*;
pub use discovery::*;
pub use nest::*;
pub use persona_card::*;
pub use personas::*;
pub use runtime::*;
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<std::path::PathBuf> {
use std::sync::OnceLock;
static HOME: OnceLock<Option<std::path::PathBuf>> = OnceLock::new();
HOME.get_or_init(|| dirs::home_dir().filter(|p| p.is_dir()))
static WORKDIR: OnceLock<Option<std::path::PathBuf>> = 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)
}
233 changes: 233 additions & 0 deletions desktop/src-tauri/src/managed_agents/nest.rs
Original file line number Diff line number Diff line change
@@ -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<PathBuf> {
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"
);
}
}
71 changes: 71 additions & 0 deletions desktop/src-tauri/src/managed_agents/nest_agents.md
Original file line number Diff line number Diff line change
@@ -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=<last_seen_unix_ts>)` 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
Loading