use anyhow::{Context, Result}; use goose::config::Config; use goose::conversation::message::Message; use goose::conversation::Conversation; use std::fs; use std::io::Read; use std::io::Write; use std::path::Path; use std::process::Command; use tempfile::Builder; use tempfile::NamedTempFile; /// Resolve the editor command from config and environment variables. /// Checks GOOSE_PROMPT_EDITOR, then $VISUAL, then $EDITOR. pub fn resolve_editor_command() -> Option { let config = Config::global(); let config_editor = config.get_goose_prompt_editor().ok().flatten(); let visual = std::env::var("VISUAL").ok(); let editor_env = std::env::var("EDITOR").ok(); resolve_editor_from_sources( config_editor.as_deref(), visual.as_deref(), editor_env.as_deref(), ) } fn resolve_editor_from_sources( config_editor: Option<&str>, visual: Option<&str>, editor_env: Option<&str>, ) -> Option { for cmd in [config_editor, visual, editor_env].into_iter().flatten() { if !cmd.is_empty() { return Some(cmd.to_string()); } } None } /// Resolve the editor command, falling back to vi (or notepad on Windows). pub fn resolve_editor_or_default() -> String { let config = Config::global(); let config_editor = config.get_goose_prompt_editor().ok().flatten(); let visual = std::env::var("VISUAL").ok(); let editor_env = std::env::var("EDITOR").ok(); resolve_editor_or_default_from_sources( config_editor.as_deref(), visual.as_deref(), editor_env.as_deref(), ) } fn resolve_editor_default() -> String { if cfg!(windows) { "notepad".to_string() } else { "vi".to_string() } } fn resolve_editor_or_default_from_sources( config_editor: Option<&str>, visual: Option<&str>, editor_env: Option<&str>, ) -> String { resolve_editor_from_sources(config_editor, visual, editor_env) .unwrap_or_else(resolve_editor_default) } /// Open a YAML temp file with the user's editor to edit a conversation. /// Returns the edited conversation, or an error if the editor failed or YAML was invalid. pub fn edit_conversation(conversation: &Conversation) -> Result { let yaml = serde_yaml::to_string(conversation.messages())?; let mut tmp = NamedTempFile::with_suffix(".yaml")?; tmp.write_all(yaml.as_bytes())?; tmp.flush()?; let editor = resolve_editor_or_default(); let path = tmp.path().to_path_buf(); launch_editor(&editor, &path).with_context(|| format!("failed to launch editor '{editor}'"))?; let edited = std::fs::read_to_string(&path)?; let messages: Vec = serde_yaml::from_str(&edited).context("invalid YAML — session unchanged")?; Ok(Conversation::new_unvalidated(messages)) } /// Build the markdown template content for the editor prompt. fn build_template(messages: &[&str], prefill: Option<&str>) -> String { let mut content = String::from("# Goose Prompt Editor\n\n"); content.push_str("# Your prompt:\n\n"); if let Some(text) = prefill { if !text.is_empty() { content.push_str(text); content.push('\n'); } } if !messages.is_empty() { content.push_str("# Recent conversation for context (newest first):\n\n"); for message in messages.iter().rev() { content.push_str(&format!("{}\n", message)); } content.push('\n'); } content } /// Create temporary markdown file with conversation history and optional prefill text fn create_temp_file(messages: &[&str], prefill: Option<&str>) -> Result { let temp_file = Builder::new() .prefix("goose_prompt_") .suffix(".md") .tempfile()?; fs::write(temp_file.path(), build_template(messages, prefill))?; Ok(temp_file) } /// Split an editor command into program and arguments. /// /// Uses shell-word splitting only when the command contains quotes, so values like /// `"/Applications/Sublime Text.app/.../subl" -w` work. Unquoted commands are split on /// whitespace to avoid shlex stripping backslashes from Windows paths like /// `C:\Windows\System32\notepad.exe`. fn split_editor_command(editor_cmd: &str) -> Result> { if editor_cmd.contains(['"', '\'']) { shlex::split(editor_cmd).ok_or_else(|| { anyhow::anyhow!("Invalid editor command: unmatched quotes in '{editor_cmd}'") }) } else { Ok(editor_cmd.split_whitespace().map(String::from).collect()) } } /// Launch editor and wait for completion fn launch_editor(editor_cmd: &str, file_path: &Path) -> Result<()> { use std::process::Stdio; let parts = split_editor_command(editor_cmd)?; if parts.is_empty() { return Err(anyhow::anyhow!("Empty editor command")); } let mut cmd = Command::new(&parts[0]); if let Ok(cwd) = std::env::current_dir() { cmd.current_dir(cwd); } if parts.len() > 1 { cmd.args(&parts[1..]); } cmd.arg(file_path) .stdin(Stdio::inherit()) .stdout(Stdio::inherit()) .stderr(Stdio::inherit()); let status = cmd.status()?; if !status.success() { return Err(anyhow::anyhow!( "Editor exited with non-zero status: {}", status.code().unwrap_or(-1) )); } Ok(()) } /// Main function to get input from editor pub fn get_editor_input( editor_cmd: &str, messages: &[&str], prefill: Option<&str>, ) -> Result<(String, bool)> { let temp_file = create_temp_file(messages, prefill)?; let temp_path = temp_file.path().to_path_buf(); launch_editor(editor_cmd, &temp_path)?; let mut content = String::new(); let mut file = temp_file.reopen()?; file.read_to_string(&mut content)?; let user_input = extract_user_input(&content); let has_meaningful_content = !user_input.trim().is_empty(); Ok((user_input, has_meaningful_content)) } /// Extract only the user's input from the markdown file fn extract_user_input(content: &str) -> String { if let Some(start) = content.find("# Your prompt:") { let marker_len = "# Your prompt:".len(); #[allow(clippy::string_slice)] let user_section = &content[start + marker_len..]; let end_patterns = [ "# Recent conversation for context", "# Recent conversation for context (newest first):", ]; let mut end_pos = None; for pattern in &end_patterns { if let Some(pos) = user_section.find(pattern) { end_pos = Some(pos); break; } } let user_input_section = match end_pos { Some(pos) => { #[allow(clippy::string_slice)] &user_section[..pos] } None => user_section, }; user_input_section.trim().to_string() } else { content.trim().to_string() } } #[cfg(test)] mod tests { use super::*; use std::fs; #[test] fn test_extract_user_input_with_editor_output() { let content = r#"# Goose Prompt Editor # Your prompt: This is the hardcoded prompt response # Recent conversation for context (newest first): ## User: Hello ## Assistant: Hi there! "#; let result = extract_user_input(content); assert_eq!(result, "This is the hardcoded prompt response"); } #[test] fn test_extract_user_input_no_marker() { let content = "Just plain text without markers"; let result = extract_user_input(content); assert_eq!(result, "Just plain text without markers"); } #[test] fn test_extract_user_input_conversation_history_heading() { let content = r#"# Goose Prompt Editor # Your prompt: This is the user's input # Recent conversation for context (newest first): ## User: Previous message ## Assistant: Previous response "#; let result = extract_user_input(content); assert_eq!(result, "This is the user's input"); } #[test] fn test_create_temp_file_with_messages() { let messages = vec!["## User: Hello", "## Assistant: Hi there!"]; let temp_file = create_temp_file(&messages, None).unwrap(); let path = temp_file.path(); assert!(path.exists()); assert!(path.to_str().unwrap().contains("goose_prompt_")); assert!(path.to_str().unwrap().ends_with(".md")); let content = fs::read_to_string(path).unwrap(); assert!(content.contains("# Goose Prompt Editor")); assert!(content.contains("## User: Hello")); assert!(content.contains("## Assistant: Hi there!")); assert!(content.contains("# Your prompt:")); assert!(content.contains("# Recent conversation for context (newest first):")); } #[test] fn test_create_temp_file_with_prefill() { let messages = vec!["## User: Hello"]; let temp_file = create_temp_file(&messages, Some("fix the login bug")).unwrap(); let content = fs::read_to_string(temp_file.path()).unwrap(); assert!(content.contains("# Your prompt:")); assert!(content.contains("fix the login bug")); // Prefill text should appear before conversation context let prefill_pos = content.find("fix the login bug").unwrap(); let context_pos = content.find("# Recent conversation for context").unwrap(); assert!( prefill_pos < context_pos, "Prefill text should appear before conversation context" ); } #[test] fn test_create_temp_file_without_prefill() { let messages = vec!["## User: Hello"]; let temp_file = create_temp_file(&messages, None).unwrap(); let content = fs::read_to_string(temp_file.path()).unwrap(); assert!(content.contains("# Your prompt:")); assert!(!content.contains("fix the login bug")); } #[test] fn test_create_temp_file_with_prefix_suffix() { let temp_file = Builder::new() .prefix("goose_test_") .suffix(".md") .tempfile() .unwrap(); let name = temp_file.path().file_name().unwrap().to_str().unwrap(); assert!(name.starts_with("goose_test_")); assert!(name.ends_with(".md")); } #[test] fn test_extract_user_input() { let content = r#"# Goose Prompt Editor # Recent conversation for context: # Your prompt: This is the user's actual input with multiple lines. "#; let result = extract_user_input(content); assert_eq!( result, "This is the user's actual input\nwith multiple lines." ); } #[test] fn test_tempfile_cleanup() { let path = { let temp_file = Builder::new() .prefix("goose_cleanup_test_") .tempfile() .unwrap(); let path = temp_file.path().to_path_buf(); assert!(path.exists()); path }; assert!(!path.exists()); } #[test] fn test_message_ordering_newest_first() { let messages = vec![ "## User: First message", "## Assistant: First response", "## User: Second message", "## Assistant: Second response", "## User: Third message (newest)", ]; let temp_file = create_temp_file(&messages, None).unwrap(); let content = fs::read_to_string(temp_file.path()).unwrap(); let newest_first = [ "## User: Third message (newest)", "## Assistant: Second response", "## User: Second message", "## Assistant: First response", "## User: First message", ]; for expected_msg in &newest_first { assert!( content.contains(expected_msg), "Expected to find message '{}' in content", expected_msg ); } let newest_pos = content.find("## User: Third message (newest)").unwrap(); let oldest_pos = content.find("## User: First message").unwrap(); assert!( newest_pos < oldest_pos, "Newest message should appear before oldest message" ); } #[test] fn test_resolve_editor_resolution_priority() { assert_eq!( resolve_editor_from_sources(Some("config-val"), Some("visual-val"), Some("editor-val")), Some("config-val".to_string()) ); assert_eq!( resolve_editor_from_sources(Some(""), Some("visual-val"), Some("editor-val")), Some("visual-val".to_string()) ); assert_eq!( resolve_editor_from_sources(None, Some(""), Some("editor-val")), Some("editor-val".to_string()) ); assert_eq!(resolve_editor_from_sources(None, None, None), None); assert_eq!( resolve_editor_from_sources(Some(""), Some(""), Some("")), None ); let default_val = resolve_editor_default(); assert_eq!( resolve_editor_or_default_from_sources(None, None, None), default_val ); assert_eq!( resolve_editor_or_default_from_sources(Some(""), Some(""), Some("")), default_val ); } #[test] fn test_split_editor_command() { assert_eq!( split_editor_command("code --wait").unwrap(), vec!["code", "--wait"] ); assert_eq!( split_editor_command( r#""/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl" -w"# ) .unwrap(), vec![ "/Applications/Sublime Text.app/Contents/SharedSupport/bin/subl", "-w" ] ); assert_eq!( split_editor_command(r"C:\Windows\System32\notepad.exe").unwrap(), vec![r"C:\Windows\System32\notepad.exe"] ); assert!(split_editor_command(r#"code --wait "unclosed"#).is_err()); } // --- build_template edge case tests --- #[test] fn test_build_template_empty_prefill_string() { let content = build_template(&["## User: Hello"], Some("")); assert!(content.contains("# Your prompt:\n\n#")); assert!(content.contains("# Recent conversation for context")); } #[test] fn test_build_template_prefill_with_no_messages() { let content = build_template(&[], Some("fix the bug")); assert!(content.contains("# Your prompt:\n\nfix the bug\n")); assert!(!content.contains("# Recent conversation for context")); } #[test] fn test_build_template_no_prefill_no_messages() { let content = build_template(&[], None); assert_eq!(content, "# Goose Prompt Editor\n\n# Your prompt:\n\n"); } #[test] fn test_build_template_prefill_with_messages() { let content = build_template(&["## User: Hi", "## Assistant: Hello"], Some("do stuff")); assert!(content.contains("do stuff")); assert!(content.contains("## User: Hi")); let prefill_pos = content.find("do stuff").unwrap(); let context_pos = content.find("# Recent conversation").unwrap(); assert!(prefill_pos < context_pos); } #[test] fn test_extract_user_input_with_prefill_kept() { let content = build_template(&["## User: Hello"], Some("fix the login bug")); let result = extract_user_input(&content); assert_eq!(result, "fix the login bug"); } #[test] fn test_extract_user_input_with_prefill_edited() { let mut content = build_template(&["## User: Hello"], Some("fix the login bug")); content = content.replace( "fix the login bug", "fix the login bug and also the signup flow", ); let result = extract_user_input(&content); assert_eq!(result, "fix the login bug and also the signup flow"); } #[test] fn test_extract_user_input_prefill_replaced() { let mut content = build_template(&["## User: Hello"], Some("fix the login bug")); content = content.replace("fix the login bug\n", "completely different prompt\n"); let result = extract_user_input(&content); assert_eq!(result, "completely different prompt"); } #[test] fn test_extract_user_input_prefill_cleared() { let mut content = build_template(&["## User: Hello"], Some("fix the login bug")); content = content.replace("fix the login bug\n", ""); let result = extract_user_input(&content); assert_eq!(result, ""); } #[test] fn test_extract_user_input_multiline_with_prefill() { let mut content = build_template(&["## User: Hello"], Some("line one")); content = content.replace("line one\n", "line one\nline two\nline three\n"); let result = extract_user_input(&content); assert_eq!(result, "line one\nline two\nline three"); } #[test] #[cfg(unix)] fn test_editor_uses_random_tempfile_and_preserves_cwd() { let script = Builder::new() .prefix("goose_editor_test_") .tempfile() .unwrap(); let path_report = Builder::new() .prefix("goose_editor_path_") .tempfile() .unwrap(); let cwd_report = Builder::new() .prefix("goose_editor_cwd_") .tempfile() .unwrap(); let secret = Builder::new() .prefix("goose_editor_secret_") .tempfile() .unwrap(); for path in [ script.path(), path_report.path(), cwd_report.path(), secret.path(), ] { assert!(!path.to_string_lossy().contains(char::is_whitespace)); } fs::write(secret.path(), "TOP_SECRET_EXFIL_abc123").unwrap(); let predictable_path = Path::new(".goose_prompt_temp.md"); assert!(!predictable_path.exists()); fs::write( script.path(), r#"printf '%s' "$4" > "$1" pwd > "$2" printf '# Goose Prompt Editor\n\n# Your prompt:\n\nupdated prompt\n' > "$4" ln -sf "$3" .goose_prompt_temp.md "#, ) .unwrap(); let editor_cmd = format!( "sh {} {} {} {}", script.path().display(), path_report.path().display(), cwd_report.path().display(), secret.path().display() ); let result = get_editor_input(&editor_cmd, &["## User: previous"], None); let malicious_target = fs::read_link(predictable_path).unwrap(); fs::remove_file(predictable_path).unwrap(); assert_eq!(malicious_target, secret.path()); let (input, has_content) = result.unwrap(); let edited_path = fs::read_to_string(path_report.path()).unwrap(); let edited_path = Path::new(edited_path.trim()); assert!(edited_path .file_name() .unwrap() .to_string_lossy() .starts_with("goose_prompt_")); assert_ne!(edited_path, Path::new(".goose_prompt_temp.md")); assert!(!edited_path.exists()); assert_eq!( Path::new(fs::read_to_string(cwd_report.path()).unwrap().trim()), std::env::current_dir().unwrap() ); assert_eq!(input, "updated prompt"); assert!(!input.contains("TOP_SECRET_EXFIL_abc123")); assert!(has_content); } }