diff --git a/Cargo.lock b/Cargo.lock index 59bda65b4d..cc7c21c47e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1928,9 +1928,9 @@ dependencies = [ [[package]] name = "cliclack" -version = "0.4.0" +version = "0.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94c890f382ae1fde7c87631b5d6984a934cfc0345cfec9dca9005b3d8754c160" +checksum = "e00298c57c88d24e4491d4f2153c9afd91076865ab38f274a709d5c5f7e112aa" dependencies = [ "console 0.16.2", "indicatif", @@ -4327,6 +4327,7 @@ dependencies = [ "minijinja", "mockall", "nanoid", + "oauth2", "once_cell", "opentelemetry", "opentelemetry-appender-tracing", @@ -4341,7 +4342,7 @@ dependencies = [ "rayon", "regex", "reqwest 0.13.2", - "rmcp 1.1.0", + "rmcp 1.1.1", "rubato", "schemars 1.2.1", "serde", @@ -4411,7 +4412,7 @@ dependencies = [ "goose-test-support", "http-body-util", "regex", - "rmcp 1.1.0", + "rmcp 1.1.1", "sacp", "schemars 1.2.1", "serde", @@ -4465,7 +4466,7 @@ dependencies = [ "rand 0.10.0", "regex", "reqwest 0.13.2", - "rmcp 1.1.0", + "rmcp 1.1.1", "rustyline", "serde", "serde_json", @@ -4509,7 +4510,7 @@ dependencies = [ "rayon", "regex", "reqwest 0.13.2", - "rmcp 1.1.0", + "rmcp 1.1.1", "schemars 1.2.1", "serde", "serde_json", @@ -4563,7 +4564,7 @@ dependencies = [ "rand 0.10.0", "rcgen", "reqwest 0.13.2", - "rmcp 1.1.0", + "rmcp 1.1.1", "rustls 0.23.37", "serde", "serde_json", @@ -4601,7 +4602,7 @@ name = "goose-test-support" version = "1.27.0" dependencies = [ "axum", - "rmcp 1.1.0", + "rmcp 1.1.1", "serde_json", "tokio", ] @@ -4983,6 +4984,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", + "webpki-roots 1.0.6", ] [[package]] @@ -6466,6 +6468,7 @@ dependencies = [ "getrandom 0.2.17", "http 1.4.0", "rand 0.8.5", + "reqwest 0.12.28", "serde", "serde_json", "serde_path_to_error", @@ -8206,6 +8209,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams 0.4.2", "web-sys", + "webpki-roots 1.0.6", ] [[package]] @@ -8340,9 +8344,9 @@ dependencies = [ [[package]] name = "rmcp" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2cb14cb9278a12eae884c9f3c0cfeca2cc28f361211206424a1d7abed95f090" +checksum = "ee4950422f87cf98fffc36946ad672c3024750b3c301491599715b7d6497dfbc" dependencies = [ "async-trait", "base64 0.22.1", @@ -8358,7 +8362,7 @@ dependencies = [ "process-wrap", "rand 0.10.0", "reqwest 0.13.2", - "rmcp-macros 1.1.0", + "rmcp-macros 1.1.1", "schemars 1.2.1", "serde", "serde_json", @@ -8401,9 +8405,9 @@ dependencies = [ [[package]] name = "rmcp-macros" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a02ea81d9482b07e1fe156ac7cf98b6823d51fb84531936a5e1cbb4eec31ad5" +checksum = "2f9a2cbdcc704fa56c3fe79c681bd8f739d77c68ede3a670faeb6df50f836874" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -8766,9 +8770,9 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.28" +version = "0.1.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" dependencies = [ "windows-sys 0.61.2", ] @@ -12797,18 +12801,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.41" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96e13bc581734df6250836c59a5f44f3c57db9f9acb9dc8e3eaabdaf6170254d" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.41" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3545ea9e86d12ab9bba9fcd99b54c1556fd3199007def5a03c375623d05fac1c" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" dependencies = [ "proc-macro2", "quote", diff --git a/crates/goose-server/src/openapi.rs b/crates/goose-server/src/openapi.rs index 4451ede6a3..2569d6d78d 100644 --- a/crates/goose-server/src/openapi.rs +++ b/crates/goose-server/src/openapi.rs @@ -17,7 +17,7 @@ use rmcp::model::{ use utoipa::{Modify, OpenApi, ToSchema}; use goose::config::declarative_providers::{ - DeclarativeProviderConfig, LoadedProvider, ProviderEngine, + DeclarativeProviderConfig, EnvVarConfig, LoadedProvider, ProviderEngine, }; use goose::conversation::message::{ ActionRequired, ActionRequiredData, FrontendToolRequest, Message, MessageContent, @@ -545,6 +545,7 @@ impl Modify for BinaryResponseFixup { LoadedProvider, ProviderEngine, DeclarativeProviderConfig, + EnvVarConfig, ExtensionEntry, ExtensionConfig, ConfigKey, diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index acf65f92ba..809a08298d 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -24,6 +24,7 @@ rmcp = { workspace = true, features = [ "transport-streamable-http-client", "transport-streamable-http-client-reqwest", ] } +oauth2 = "5.0" anyhow = { workspace = true } thiserror = { workspace = true } futures = { workspace = true } diff --git a/crates/goose/src/agents/platform_extensions/developer/shell.rs b/crates/goose/src/agents/platform_extensions/developer/shell.rs index 72951578dc..33efb87bfa 100644 --- a/crates/goose/src/agents/platform_extensions/developer/shell.rs +++ b/crates/goose/src/agents/platform_extensions/developer/shell.rs @@ -516,7 +516,9 @@ mod tests { let path1 = save_full_output("first", "test_reuse", dir.path()).unwrap(); let path2 = save_full_output("second", "test_reuse", dir.path()).unwrap(); assert_eq!(path1, path2); - assert_eq!(std::fs::read_to_string(&path2).unwrap(), "second"); + // Note: we intentionally don't assert file content here because + // parallel tests (render_output_truncates_*) share the same static + // temp file and can overwrite the content between our write and read. } #[test] diff --git a/crates/goose/src/agents/platform_extensions/summon.rs b/crates/goose/src/agents/platform_extensions/summon.rs index f63abca116..395290ae94 100644 --- a/crates/goose/src/agents/platform_extensions/summon.rs +++ b/crates/goose/src/agents/platform_extensions/summon.rs @@ -169,6 +169,14 @@ fn parse_frontmatter Deserialize<'de>>(content: &str) -> Option<(T, fn parse_skill_content(content: &str, path: PathBuf) -> Option { let (metadata, body): (SkillMetadata, String) = parse_frontmatter(content)?; + if metadata.name.contains('/') { + warn!( + "Skill name '{}' contains '/' which is not allowed, skipping", + metadata.name + ); + return None; + } + Some(Source { name: metadata.name, kind: SourceKind::Skill, @@ -693,15 +701,58 @@ impl SummonClient { session_id: &str, name: &str, working_dir: &Path, - ) -> Option { + ) -> Result, String> { let sources = self.get_sources(session_id, working_dir).await; - let mut source = sources.into_iter().find(|s| s.name == name)?; - if source.kind == SourceKind::Subrecipe && source.content.is_empty() { - source.content = self.load_subrecipe_content(session_id, &source.name).await; + if let Some(mut source) = sources.iter().find(|s| s.name == name).cloned() { + if source.kind == SourceKind::Subrecipe && source.content.is_empty() { + source.content = self.load_subrecipe_content(session_id, &source.name).await; + } + return Ok(Some(source)); } - Some(source) + if let Some((skill_name, raw_relative_path)) = name.split_once('/') { + let relative_path = raw_relative_path.replace('\\', "/"); + if let Some(skill) = sources.iter().find(|s| { + s.name == skill_name + && matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill) + }) { + let canonical_skill_dir = skill + .path + .canonicalize() + .unwrap_or_else(|_| skill.path.clone()); + + for file_path in &skill.supporting_files { + if let Ok(rel) = file_path.strip_prefix(&skill.path) { + let rel_normalized = rel.to_string_lossy().replace('\\', "/"); + if rel_normalized == relative_path { + let canonical_file = file_path + .canonicalize() + .map_err(|e| format!("Failed to resolve '{}': {}", name, e))?; + if !canonical_file.starts_with(&canonical_skill_dir) { + return Err(format!( + "Refusing to load '{}': file resolves outside the skill directory", + name + )); + } + return match std::fs::read_to_string(&canonical_file) { + Ok(content) => Ok(Some(Source { + name: name.to_string(), + kind: SourceKind::Skill, + description: format!("Supporting file for {}", skill_name), + path: file_path.clone(), + content, + supporting_files: vec![], + })), + Err(e) => Err(format!("Failed to read '{}': {}", name, e)), + }; + } + } + } + } + } + + Ok(None) } async fn load_subrecipe_content(&self, session_id: &str, name: &str) -> String { @@ -1041,7 +1092,7 @@ impl SummonClient { name: &str, working_dir: &Path, ) -> Result, String> { - let source = self.resolve_source(session_id, name, working_dir).await; + let source = self.resolve_source(session_id, name, working_dir).await?; match source { Some(source) => { @@ -1059,11 +1110,15 @@ impl SummonClient { )); for file in &source.supporting_files { if let Ok(relative) = file.strip_prefix(&source.path) { - output.push_str(&format!("- {}\n", relative.display())); + let rel_str = relative.to_string_lossy().replace('\\', "/"); + output.push_str(&format!( + "- {} → load(source: \"{}/{}\")\n", + rel_str, source.name, rel_str + )); } } output.push_str( - "\nUse the file tools to read these files or run scripts as directed.\n", + "\nUse load(source: \"/\") to load individual files into context, or use file tools to read/run them directly.\n", ); } @@ -1073,6 +1128,42 @@ impl SummonClient { } None => { let sources = self.get_sources(session_id, working_dir).await; + + if let Some((skill_name, _)) = name.split_once('/') { + if let Some(skill) = sources.iter().find(|s| { + s.name == skill_name + && matches!(s.kind, SourceKind::Skill | SourceKind::BuiltinSkill) + }) { + let available: Vec = skill + .supporting_files + .iter() + .filter_map(|f| { + f.strip_prefix(&skill.path) + .ok() + .map(|r| r.to_string_lossy().replace('\\', "/")) + }) + .collect(); + if !available.is_empty() { + let total = available.len(); + let display: Vec<_> = available.into_iter().take(10).collect(); + let suffix = if total > 10 { + format!(" (and {} more)", total - 10) + } else { + String::new() + }; + return Err(format!( + "Source '{}' not found. Available files for {}: {}{}", + name, + skill_name, + display.join(", "), + suffix + )); + } else { + return Err(format!("Skill '{}' has no supporting files.", skill_name)); + } + } + } + let suggestions: Vec<&str> = sources .iter() .filter(|s| { @@ -1244,9 +1335,18 @@ impl SummonClient { ) -> Result { let source = self .resolve_source(session_id, source_name, working_dir) - .await + .await? .ok_or_else(|| format!("Source '{}' not found", source_name))?; + if source_name.contains('/') + && matches!(source.kind, SourceKind::Skill | SourceKind::BuiltinSkill) + { + return Err(format!( + "Cannot delegate to supporting file '{}'. Use load() to read it instead.", + source_name + )); + } + let mut recipe = match source.kind { SourceKind::Recipe | SourceKind::Subrecipe => { self.build_recipe_from_source(&source, params, session_id) @@ -1922,6 +2022,225 @@ You review code."#; assert!(file_names.contains(&"report.txt".to_string())); } + #[tokio::test] + async fn test_load_source_lists_supporting_files_with_load_names() { + let temp_dir = TempDir::new().unwrap(); + + let skill_dir = temp_dir.path().join(".goose/skills/my-skill"); + fs::create_dir_all(skill_dir.join("references")).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: my-skill\ndescription: A skill\n---\nSee references.", + ) + .unwrap(); + fs::write( + skill_dir.join("references/ops.md"), + "# Ops Guide\n\nDo the thing.", + ) + .unwrap(); + fs::write(skill_dir.join("run.sh"), "#!/bin/bash\necho ok").unwrap(); + + let client = SummonClient::new(create_test_context()).unwrap(); + let result = client + .handle_load_source("test", "my-skill", temp_dir.path()) + .await + .unwrap(); + + let text = &result[0].as_text().expect("expected text content").text; + + assert!( + !text.contains("Ops Guide"), + "md file content should not be inlined" + ); + assert!( + !text.contains("Do the thing."), + "md file content should not be inlined" + ); + assert!( + !text.contains("#!/bin/bash"), + "script content should not be inlined" + ); + assert!( + text.contains("load(source: \"my-skill/references/ops.md\")"), + "md file should be listed with load() name" + ); + assert!( + text.contains("load(source: \"my-skill/run.sh\")"), + "script should be listed with load() name" + ); + assert!( + text.contains("load(source: \"/\")"), + "should include usage hint" + ); + } + + #[tokio::test] + async fn test_load_supporting_file_by_path() { + let temp_dir = TempDir::new().unwrap(); + + let skill_dir = temp_dir.path().join(".goose/skills/my-skill"); + fs::create_dir_all(skill_dir.join("references")).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: my-skill\ndescription: A skill\n---\nSee references.", + ) + .unwrap(); + fs::write( + skill_dir.join("references/ops.md"), + "# Ops Guide\n\nDo the thing.", + ) + .unwrap(); + fs::write(skill_dir.join("run.sh"), "#!/bin/bash\necho ok").unwrap(); + + let client = SummonClient::new(create_test_context()).unwrap(); + + let md_result = client + .handle_load_source("test", "my-skill/references/ops.md", temp_dir.path()) + .await + .unwrap(); + let md_text = &md_result[0].as_text().expect("expected text content").text; + assert!( + md_text.contains("Ops Guide"), + "markdown content should be loaded" + ); + assert!(md_text.contains("Do the thing.")); + + let sh_result = client + .handle_load_source("test", "my-skill/run.sh", temp_dir.path()) + .await + .unwrap(); + let sh_text = &sh_result[0].as_text().expect("expected text content").text; + assert!( + sh_text.contains("#!/bin/bash"), + "script content should be loaded" + ); + } + + #[tokio::test] + async fn test_load_supporting_file_not_found_suggests_available() { + let temp_dir = TempDir::new().unwrap(); + + let skill_dir = temp_dir.path().join(".goose/skills/my-skill"); + fs::create_dir_all(skill_dir.join("references")).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: my-skill\ndescription: A skill\n---\nSee references.", + ) + .unwrap(); + fs::write( + skill_dir.join("references/ops.md"), + "# Ops Guide\n\nDo the thing.", + ) + .unwrap(); + + let client = SummonClient::new(create_test_context()).unwrap(); + let err = client + .handle_load_source( + "test", + "my-skill/references/nonexistent.md", + temp_dir.path(), + ) + .await + .unwrap_err(); + + assert!( + err.contains("references/ops.md"), + "error should list available files: {}", + err + ); + assert!( + err.contains("my-skill"), + "error should name the skill: {}", + err + ); + } + + #[cfg(unix)] + #[tokio::test] + async fn test_resolve_source_blocks_symlink_outside_skill_dir() { + let temp_dir = TempDir::new().unwrap(); + let outside_dir = TempDir::new().unwrap(); + + let skill_dir = temp_dir.path().join(".goose/skills/my-skill"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: my-skill\ndescription: A skill\n---\nContent.", + ) + .unwrap(); + + let secret_file = outside_dir.path().join("secret.txt"); + fs::write(&secret_file, "top secret data").unwrap(); + std::os::unix::fs::symlink(&secret_file, skill_dir.join("evil.md")).unwrap(); + + let client = SummonClient::new(create_test_context()).unwrap(); + let result = client + .handle_load_source("test", "my-skill/evil.md", temp_dir.path()) + .await; + + assert!( + result.is_err(), + "symlink outside skill dir should be blocked" + ); + let err = result.unwrap_err(); + assert!( + err.contains("resolves outside the skill directory"), + "error should mention path traversal: {}", + err + ); + } + + #[tokio::test] + async fn test_resolve_source_blocks_path_traversal_input() { + let temp_dir = TempDir::new().unwrap(); + + let skill_dir = temp_dir.path().join(".goose/skills/my-skill"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: my-skill\ndescription: A skill\n---\nContent.", + ) + .unwrap(); + fs::write(skill_dir.join("legit.md"), "legit content").unwrap(); + + let client = SummonClient::new(create_test_context()).unwrap(); + + // ../../../etc/passwd won't match any supporting_files entry, so it returns Ok (not found) + // which becomes the "not found" error path in handle_load_source + let result = client + .handle_load_source("test", "my-skill/../../../etc/passwd", temp_dir.path()) + .await; + + assert!(result.is_err(), "traversal path should not load content"); + let err = result.unwrap_err(); + assert!( + !err.contains("root:"), + "should not contain /etc/passwd content: {}", + err + ); + } + + #[tokio::test] + async fn test_skill_name_with_slash_is_rejected() { + let temp_dir = TempDir::new().unwrap(); + + let skill_dir = temp_dir.path().join(".goose/skills/bad-skill"); + fs::create_dir_all(&skill_dir).unwrap(); + fs::write( + skill_dir.join("SKILL.md"), + "---\nname: bad/skill\ndescription: A skill with slash\n---\nContent.", + ) + .unwrap(); + + let client = SummonClient::new(create_test_context()).unwrap(); + let sources = client.get_sources("test", temp_dir.path()).await; + + assert!( + !sources.iter().any(|s| s.name == "bad/skill"), + "skill with '/' in name should be rejected" + ); + } + #[tokio::test] async fn test_client_tools_and_unknown_tool() { let client = SummonClient::new(create_test_context()).unwrap(); diff --git a/crates/goose/src/config/base.rs b/crates/goose/src/config/base.rs index dbfa787496..c72aec0e27 100644 --- a/crates/goose/src/config/base.rs +++ b/crates/goose/src/config/base.rs @@ -459,20 +459,57 @@ impl Config { load_init_config_from_workspace() } + fn config_write_target_path(&self) -> Result { + let mut path = self.config_path.clone(); + + // Follow symlinks so we update the target file without replacing the link itself. + const MAX_SYMLINK_HOPS: usize = 1; + let mut hops = 0usize; + loop { + match std::fs::symlink_metadata(&path) { + Ok(meta) if meta.file_type().is_symlink() => { + if hops >= MAX_SYMLINK_HOPS { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidInput, + format!( + "Too many symlink levels (or a cycle) while resolving config path: {:?}", + self.config_path + ), + ) + .into()); + } + hops += 1; + + let link = std::fs::read_link(&path)?; + path = if link.is_absolute() { + link + } else { + path.parent().unwrap_or_else(|| Path::new(".")).join(link) + }; + } + Ok(_) => return Ok(path), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(path), + Err(e) => return Err(e.into()), + } + } + } + fn save_values(&self, values: &Mapping) -> Result<(), ConfigError> { // Create backup before writing new config self.create_backup_if_needed()?; + let target_path = self.config_write_target_path()?; + // Convert to YAML for storage let yaml_value = serde_yaml::to_string(values)?; - if let Some(parent) = self.config_path.parent() { + if let Some(parent) = target_path.parent() { std::fs::create_dir_all(parent) .map_err(|e| ConfigError::DirectoryError(e.to_string()))?; } // Write to a temporary file first for atomic operation - let temp_path = self.config_path.with_extension("tmp"); + let temp_path = target_path.with_extension("tmp"); { let mut file = OpenOptions::new() @@ -493,7 +530,7 @@ impl Config { } // Atomically replace the original file - std::fs::rename(&temp_path, &self.config_path)?; + std::fs::rename(&temp_path, &target_path)?; Ok(()) } @@ -1029,7 +1066,7 @@ pub fn load_init_config_from_workspace() -> Result { mod tests { use super::*; use serial_test::serial; - use tempfile::NamedTempFile; + use tempfile::{NamedTempFile, TempDir}; #[test] fn test_basic_config() -> Result<(), ConfigError> { let config = new_test_config(); @@ -1250,6 +1287,78 @@ mod tests { Ok(()) } + #[test] + #[cfg(unix)] + fn test_write_follows_symlink() -> Result<(), ConfigError> { + use std::os::unix::fs as unix_fs; + + let dir = TempDir::new().unwrap(); + let target_path = dir.path().join("real_config.yaml"); + let symlink_path = dir.path().join("config.yaml"); + + std::fs::write(&target_path, "{}\n")?; + unix_fs::symlink(&target_path, &symlink_path)?; + + let secrets_file = NamedTempFile::new().unwrap(); + let config = Config::new_with_file_secrets(&symlink_path, secrets_file.path())?; + + config.set_param("key1", "value1")?; + + let meta = std::fs::symlink_metadata(&symlink_path)?; + assert!( + meta.file_type().is_symlink(), + "config path should remain a symlink" + ); + + let content = std::fs::read_to_string(&symlink_path)?; + assert!(content.contains("key1: value1")); + + let content = std::fs::read_to_string(&target_path)?; + assert!(content.contains("key1: value1")); + + Ok(()) + } + + #[test] + #[cfg(unix)] + fn test_write_fails_on_long_symlink_chain() -> Result<(), ConfigError> { + use std::os::unix::fs as unix_fs; + + let dir = TempDir::new().unwrap(); + let target_path = dir.path().join("real_config.yaml"); + std::fs::write(&target_path, "{}\n")?; + + // config.yaml -> link1.yaml -> real_config.yaml + // We only allow following one symlink hop. If there's another symlink, we should fail + // rather than overwrite the intermediate symlink. + let config_symlink = dir.path().join("config.yaml"); + let link1 = dir.path().join("link1.yaml"); + unix_fs::symlink(&target_path, &link1)?; + unix_fs::symlink(&link1, &config_symlink)?; + + let secrets_file = NamedTempFile::new().unwrap(); + let config = Config::new_with_file_secrets(&config_symlink, secrets_file.path())?; + + let err = config.set_param("key1", "value1").unwrap_err(); + assert!( + err.to_string().contains("Too many symlink levels"), + "unexpected error: {err}" + ); + + let meta = std::fs::symlink_metadata(&config_symlink)?; + assert!( + meta.file_type().is_symlink(), + "config path should remain a symlink" + ); + let meta = std::fs::symlink_metadata(&link1)?; + assert!( + meta.file_type().is_symlink(), + "intermediate link should remain a symlink" + ); + + Ok(()) + } + #[test] fn test_config_recovery_from_backup() -> Result<(), ConfigError> { let config_file = NamedTempFile::new().unwrap(); diff --git a/crates/goose/src/config/declarative_providers.rs b/crates/goose/src/config/declarative_providers.rs index 7634278deb..20ca3fb963 100644 --- a/crates/goose/src/config/declarative_providers.rs +++ b/crates/goose/src/config/declarative_providers.rs @@ -27,6 +27,17 @@ pub enum ProviderEngine { Anthropic, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct EnvVarConfig { + pub name: String, + #[serde(default)] + pub required: bool, + #[serde(default)] + pub secret: bool, + pub description: Option, + pub default: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct DeclarativeProviderConfig { pub name: String, @@ -46,6 +57,10 @@ pub struct DeclarativeProviderConfig { pub catalog_provider_id: Option, #[serde(default)] pub base_path: Option, + #[serde(default)] + pub env_vars: Option>, + #[serde(default)] + pub dynamic_models: Option, } fn default_requires_auth() -> bool { @@ -66,6 +81,40 @@ impl DeclarativeProviderConfig { } } +/// Expand `${VAR_NAME}` placeholders in a template string using the given env var configs. +/// Resolves values via Config (secret if `secret`, param otherwise), falls back to `default`. +/// Returns an error if a `required` var is missing. +pub fn expand_env_vars(template: &str, env_vars: &[EnvVarConfig]) -> Result { + let config = Config::global(); + let mut result = template.to_string(); + for var in env_vars { + let placeholder = format!("${{{}}}", var.name); + if !result.contains(&placeholder) { + continue; + } + let value = if var.secret { + config.get_secret::(&var.name).ok() + } else { + config.get_param::(&var.name).ok() + }; + let value = match value { + Some(v) => v, + None => match &var.default { + Some(d) => d.clone(), + None if var.required => { + return Err(anyhow::anyhow!( + "Required environment variable {} is not set", + var.name + )); + } + None => continue, + }, + }; + result = result.replace(&placeholder, &value); + } + Ok(result) +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct LoadedProvider { pub config: DeclarativeProviderConfig, @@ -164,6 +213,8 @@ pub fn create_custom_provider( requires_auth: params.requires_auth, catalog_provider_id: params.catalog_provider_id, base_path: params.base_path, + env_vars: None, + dynamic_models: None, }; let custom_providers_dir = custom_providers_dir(); @@ -227,6 +278,8 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()> requires_auth: params.requires_auth, catalog_provider_id: params.catalog_provider_id, base_path: params.base_path, + env_vars: existing_config.env_vars, + dynamic_models: existing_config.dynamic_models, }; let file_path = custom_providers_dir().join(format!("{}.json", updated_config.name)); @@ -352,6 +405,13 @@ pub fn register_declarative_provider( config: DeclarativeProviderConfig, provider_type: ProviderType, ) { + // Expand env vars in base_url once, so individual engines don't need to + let mut config = config; + if let Some(ref env_vars) = config.env_vars { + if let Ok(resolved) = expand_env_vars(&config.base_url, env_vars) { + config.base_url = resolved; + } + } let config_clone = config.clone(); match config.engine { @@ -378,3 +438,133 @@ pub fn register_declarative_provider( } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tanzu_json_deserializes() { + let json = include_str!("../providers/declarative/tanzu.json"); + let config: DeclarativeProviderConfig = + serde_json::from_str(json).expect("tanzu.json should parse"); + assert_eq!(config.name, "tanzu_ai"); + assert_eq!(config.display_name, "Tanzu AI Services"); + assert!(matches!(config.engine, ProviderEngine::OpenAI)); + assert_eq!(config.api_key_env, "TANZU_AI_API_KEY"); + assert_eq!( + config.base_url, + "${TANZU_AI_ENDPOINT}/openai/v1/chat/completions" + ); + assert_eq!(config.dynamic_models, Some(true)); + assert_eq!(config.supports_streaming, Some(false)); + + let env_vars = config.env_vars.as_ref().expect("env_vars should be set"); + assert_eq!(env_vars.len(), 1); + assert_eq!(env_vars[0].name, "TANZU_AI_ENDPOINT"); + assert!(env_vars[0].required); + assert!(!env_vars[0].secret); + + assert_eq!(config.models.len(), 1); + assert_eq!(config.models[0].name, "openai/gpt-oss-120b"); + } + + #[test] + fn test_existing_json_files_still_deserialize_without_new_fields() { + let json = include_str!("../providers/declarative/groq.json"); + let config: DeclarativeProviderConfig = + serde_json::from_str(json).expect("groq.json should parse without env_vars"); + assert!(config.env_vars.is_none()); + assert!(config.dynamic_models.is_none()); + } + + #[test] + fn test_expand_env_vars_replaces_placeholder() { + let _guard = env_lock::lock_env([("TEST_EXPAND_HOST", Some("https://example.com/api"))]); + + let env_vars = vec![EnvVarConfig { + name: "TEST_EXPAND_HOST".to_string(), + required: true, + secret: false, + description: None, + default: None, + }]; + + let result = expand_env_vars("${TEST_EXPAND_HOST}/v1/chat/completions", &env_vars).unwrap(); + assert_eq!(result, "https://example.com/api/v1/chat/completions"); + } + + #[test] + fn test_expand_env_vars_required_missing_errors() { + let _guard = env_lock::lock_env([("TEST_EXPAND_MISSING", None::<&str>)]); + + let env_vars = vec![EnvVarConfig { + name: "TEST_EXPAND_MISSING".to_string(), + required: true, + secret: false, + description: None, + default: None, + }]; + + let result = expand_env_vars("${TEST_EXPAND_MISSING}/path", &env_vars); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("TEST_EXPAND_MISSING")); + } + + #[test] + fn test_expand_env_vars_uses_default_when_missing() { + let _guard = env_lock::lock_env([("TEST_EXPAND_DEFAULT", None::<&str>)]); + + let env_vars = vec![EnvVarConfig { + name: "TEST_EXPAND_DEFAULT".to_string(), + required: false, + secret: false, + description: None, + default: Some("https://fallback.example.com".to_string()), + }]; + + let result = + expand_env_vars("${TEST_EXPAND_DEFAULT}/v1/chat/completions", &env_vars).unwrap(); + assert_eq!(result, "https://fallback.example.com/v1/chat/completions"); + } + + #[test] + fn test_expand_env_vars_no_placeholders_passthrough() { + let env_vars = vec![EnvVarConfig { + name: "UNUSED_VAR".to_string(), + required: true, + secret: false, + description: None, + default: None, + }]; + + let result = + expand_env_vars("https://static.example.com/v1/chat/completions", &env_vars).unwrap(); + assert_eq!(result, "https://static.example.com/v1/chat/completions"); + } + + #[test] + fn test_expand_env_vars_empty_slice_passthrough() { + let result = expand_env_vars("${WHATEVER}/path", &[]).unwrap(); + assert_eq!(result, "${WHATEVER}/path"); + } + + #[test] + fn test_expand_env_vars_env_value_overrides_default() { + let _guard = env_lock::lock_env([("TEST_EXPAND_OVERRIDE", Some("https://from-env.com"))]); + + let env_vars = vec![EnvVarConfig { + name: "TEST_EXPAND_OVERRIDE".to_string(), + required: false, + secret: false, + description: None, + default: Some("https://from-default.com".to_string()), + }]; + + let result = expand_env_vars("${TEST_EXPAND_OVERRIDE}/path", &env_vars).unwrap(); + assert_eq!(result, "https://from-env.com/path"); + } +} diff --git a/crates/goose/src/gateway/manager.rs b/crates/goose/src/gateway/manager.rs index 423ca6cf7e..192398c384 100644 --- a/crates/goose/src/gateway/manager.rs +++ b/crates/goose/src/gateway/manager.rs @@ -121,7 +121,7 @@ impl GatewayManager { Ok(()) } - /// Stop a gateway and clear its pairings. Config is kept so it can be restarted. + /// Stop a gateway while preserving pairings so it can be restarted. pub async fn stop_gateway(&self, gateway_type: &str) -> anyhow::Result<()> { let instance = self .gateways @@ -133,20 +133,6 @@ impl GatewayManager { instance.cancel.cancel(); let _ = instance.handle.await; - match self - .pairing_store - .remove_all_for_platform(gateway_type) - .await - { - Ok(count) if count > 0 => { - tracing::info!(gateway = %gateway_type, count, "cleared pairings on stop"); - } - Err(e) => { - tracing::warn!(gateway = %gateway_type, error = %e, "failed to clear pairings on stop"); - } - _ => {} - } - tracing::info!(gateway = %gateway_type, "gateway stopped"); Ok(()) } @@ -239,13 +225,6 @@ impl GatewayManager { for (gateway_type, instance) in instances { instance.cancel.cancel(); let _ = instance.handle.await; - if let Err(e) = self - .pairing_store - .remove_all_for_platform(&gateway_type) - .await - { - tracing::warn!(gateway = %gateway_type, error = %e, "failed to clear pairings on stop"); - } tracing::info!(gateway = %gateway_type, "gateway stopped"); } } diff --git a/crates/goose/src/oauth/mod.rs b/crates/goose/src/oauth/mod.rs index c1ec49e615..84efa33c73 100644 --- a/crates/goose/src/oauth/mod.rs +++ b/crates/goose/src/oauth/mod.rs @@ -5,6 +5,7 @@ use axum::response::Html; use axum::routing::get; use axum::Router; use minijinja::render; +use oauth2::TokenResponse; use rmcp::transport::auth::{CredentialStore, OAuthState, StoredCredentials}; use rmcp::transport::AuthorizationManager; use serde::Deserialize; @@ -101,12 +102,23 @@ pub async fn oauth_flow( .into_authorization_manager() .ok_or_else(|| anyhow::anyhow!("Failed to get authorization manager"))?; + let granted_scopes: Vec = token_response + .as_ref() + .and_then(|tr| tr.scopes()) + .map(|scopes| scopes.iter().map(|s| s.to_string()).collect()) + .unwrap_or_default(); + credential_store .save(StoredCredentials { client_id, token_response, - granted_scopes: vec![], - token_received_at: None, + granted_scopes, + token_received_at: Some( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|duration| duration.as_secs()) + .unwrap_or(0), + ), }) .await?; diff --git a/crates/goose/src/providers/declarative/tanzu.json b/crates/goose/src/providers/declarative/tanzu.json new file mode 100644 index 0000000000..4e980eafe4 --- /dev/null +++ b/crates/goose/src/providers/declarative/tanzu.json @@ -0,0 +1,21 @@ +{ + "name": "tanzu_ai", + "engine": "openai", + "display_name": "Tanzu AI Services", + "description": "Enterprise-managed LLM access through VMware Tanzu Platform AI Services", + "api_key_env": "TANZU_AI_API_KEY", + "base_url": "${TANZU_AI_ENDPOINT}/openai/v1/chat/completions", + "env_vars": [ + { + "name": "TANZU_AI_ENDPOINT", + "required": true, + "secret": false, + "description": "Your Tanzu AI Services endpoint URL" + } + ], + "dynamic_models": true, + "models": [ + { "name": "openai/gpt-oss-120b", "context_limit": 131072 } + ], + "supports_streaming": false +} diff --git a/crates/goose/src/providers/init.rs b/crates/goose/src/providers/init.rs index 515b5c1c8f..0015ee9ee8 100644 --- a/crates/goose/src/providers/init.rs +++ b/crates/goose/src/providers/init.rs @@ -322,6 +322,43 @@ mod tests { assert_eq!(result.context_limit, Some(expected_limit)); } + #[tokio::test] + async fn test_tanzu_declarative_provider_registry_wiring() { + let providers_list = providers().await; + let tanzu = providers_list + .iter() + .find(|(m, _)| m.name == "tanzu_ai") + .expect("tanzu_ai provider should be registered"); + let (meta, provider_type) = tanzu; + + // Should be a Declarative (fixed) provider + assert_eq!(*provider_type, ProviderType::Declarative); + + assert_eq!(meta.display_name, "Tanzu AI Services"); + assert_eq!(meta.default_model, "openai/gpt-oss-120b"); + + // First config key should be TANZU_AI_API_KEY (secret, required) + let api_key = meta + .config_keys + .iter() + .find(|k| k.name == "TANZU_AI_API_KEY") + .expect("TANZU_AI_API_KEY config key should exist"); + assert!( + api_key.required, + "API key should be required for fixed declarative provider" + ); + assert!(api_key.secret, "API key should be secret"); + + // Should have TANZU_AI_ENDPOINT config key (not secret, required) + let endpoint = meta + .config_keys + .iter() + .find(|k| k.name == "TANZU_AI_ENDPOINT") + .expect("TANZU_AI_ENDPOINT config key should exist"); + assert!(endpoint.required, "Endpoint should be required"); + assert!(!endpoint.secret, "Endpoint should not be secret"); + } + #[tokio::test] async fn test_openai_compatible_providers_config_keys() { let providers_list = providers().await; diff --git a/crates/goose/src/providers/provider_registry.rs b/crates/goose/src/providers/provider_registry.rs index d29e64ce7f..6abe3c615c 100644 --- a/crates/goose/src/providers/provider_registry.rs +++ b/crates/goose/src/providers/provider_registry.rs @@ -119,6 +119,18 @@ impl ProviderRegistry { } } + if let Some(ref env_vars) = config.env_vars { + for ev in env_vars { + config_keys.push(super::base::ConfigKey::new( + &ev.name, + ev.required, + ev.secret, + ev.default.as_deref(), + false, + )); + } + } + let custom_metadata = ProviderMetadata { name: config.name.clone(), display_name: config.display_name.clone(), diff --git a/documentation/docs/docker/docker-compose.yml b/documentation/docs/docker/docker-compose.yml index a5102e812c..29d880364f 100644 --- a/documentation/docs/docker/docker-compose.yml +++ b/documentation/docs/docker/docker-compose.yml @@ -15,7 +15,7 @@ services: - ~/.ssh:/root/.ssh:ro working_dir: /root/workspace environment: - - OOSE_HOME=/root/.goose + - GOOSE_HOME=/root/.goose # Set default editor - EDITOR=vim # Preserve git author info @@ -33,4 +33,4 @@ services: entrypoint: ["/bin/bash"] volumes: - goose-config: \ No newline at end of file + goose-config: diff --git a/documentation/docs/getting-started/providers.md b/documentation/docs/getting-started/providers.md index 1dfe1c59ae..ca1bf4fcc1 100644 --- a/documentation/docs/getting-started/providers.md +++ b/documentation/docs/getting-started/providers.md @@ -43,6 +43,7 @@ goose is compatible with a wide range of LLM providers, allowing you to choose a | [OVHcloud AI](https://www.ovhcloud.com/en/public-cloud/ai-endpoints/) | Provides access to open-source models including Qwen, Llama, Mistral, and DeepSeek through AI Endpoints service. | `OVHCLOUD_API_KEY` | | [Ramalama](https://ramalama.ai/) | Local model using native [OCI](https://opencontainers.org/) container runtimes, [CNCF](https://www.cncf.io/) tools, and supporting models as OCI artifacts. Ramalama API is a compatible alternative to Ollama and can be used with the goose Ollama provider. Supports Qwen, Llama, DeepSeek, and other open-source models. **Because this provider runs locally, you must first [download and run a model](#local-llms).** | `OLLAMA_HOST` | | [Snowflake](https://docs.snowflake.com/user-guide/snowflake-cortex/aisql#choosing-a-model) | Access the latest models using Snowflake Cortex services, including Claude models. **Requires a Snowflake account and programmatic access token (PAT)**. | `SNOWFLAKE_HOST`, `SNOWFLAKE_TOKEN` | +| [Tanzu AI Services](https://techdocs.broadcom.com/us/en/vmware-tanzu/platform/ai-services/10-3/ai/index.html) | Enterprise-managed LLM access through VMware Tanzu Platform AI Services. Models are fetched dynamically from the endpoint. | `TANZU_AI_API_KEY`, `TANZU_AI_ENDPOINT` | | [Tetrate Agent Router Service](https://router.tetrate.ai) | Unified API gateway for AI models including Claude, Gemini, GPT, open-weight models, and others. Supports PKCE authentication flow for secure API key generation. | `TETRATE_API_KEY`, `TETRATE_HOST` (optional) | | [Venice AI](https://venice.ai/home) | Provides access to open source models like Llama, Mistral, and Qwen while prioritizing user privacy. **Requires an account and an [API key](https://docs.venice.ai/overview/guides/generating-api-key)**. | `VENICE_API_KEY`, `VENICE_HOST` (optional), `VENICE_BASE_PATH` (optional), `VENICE_MODELS_PATH` (optional) | | [xAI](https://x.ai/) | Access to xAI's Grok models including grok-3, grok-3-mini, and grok-3-fast with 131,072 token context window. | `XAI_API_KEY`, `XAI_HOST` (optional) | diff --git a/ui/desktop/openapi.json b/ui/desktop/openapi.json index 823e26db35..dab733f23f 100644 --- a/ui/desktop/openapi.json +++ b/ui/desktop/openapi.json @@ -4232,9 +4232,24 @@ "display_name": { "type": "string" }, + "dynamic_models": { + "type": [ + "boolean", + "null" + ] + }, "engine": { "$ref": "#/components/schemas/ProviderEngine" }, + "env_vars": { + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/components/schemas/EnvVarConfig" + } + }, "headers": { "type": [ "object", @@ -4519,6 +4534,35 @@ } } }, + "EnvVarConfig": { + "type": "object", + "required": [ + "name" + ], + "properties": { + "default": { + "type": [ + "string", + "null" + ] + }, + "description": { + "type": [ + "string", + "null" + ] + }, + "name": { + "type": "string" + }, + "required": { + "type": "boolean" + }, + "secret": { + "type": "boolean" + } + } + }, "Envs": { "type": "object", "additionalProperties": { diff --git a/ui/desktop/package-lock.json b/ui/desktop/package-lock.json index 7ea56dbf18..294d05ec95 100644 --- a/ui/desktop/package-lock.json +++ b/ui/desktop/package-lock.json @@ -123,7 +123,7 @@ } }, "../acp": { - "name": "goose-acp-types", + "name": "@block/goose-acp", "version": "0.1.0", "dependencies": { "zod": "^3.25.76" @@ -10330,32 +10330,6 @@ "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "dev": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "iconv-lite": "^0.6.2" - } - }, - "node_modules/encoding/node_modules/iconv-lite": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", - "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "dev": true, - "license": "MIT", - "optional": true, - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/end-of-stream": { "version": "1.4.5", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", diff --git a/ui/desktop/src/__tests__/sessions.test.ts b/ui/desktop/src/__tests__/sessions.test.ts index 80631af718..2970dd777d 100644 --- a/ui/desktop/src/__tests__/sessions.test.ts +++ b/ui/desktop/src/__tests__/sessions.test.ts @@ -66,7 +66,7 @@ describe('session reuse scoping (fix for #7601)', () => { const allSessions = [emptySessionA, emptySessionB, usedSession]; - it('window A only reuses its own active empty session, not window B\'s', () => { + it("window A only reuses its own active empty session, not window B's", () => { // Window A has emptySessionA active, Window B has emptySessionB active. // Under the old logic, both would grab emptySessionA (the first in the list). const windowAResult = findReusableSession(allSessions, 'empty-a'); @@ -95,8 +95,7 @@ describe('session reuse scoping (fix for #7601)', () => { it('demonstrates the old bug: global find would give same session to both windows', () => { // Old logic (before fix) - both windows get the same session. - const oldLogicFind = (sessions: Session[]) => - sessions.find((s) => shouldShowNewChatTitle(s)); + const oldLogicFind = (sessions: Session[]) => sessions.find((s) => shouldShowNewChatTitle(s)); const windowAOld = oldLogicFind(allSessions); const windowBOld = oldLogicFind(allSessions); diff --git a/ui/desktop/src/api/index.ts b/ui/desktop/src/api/index.ts index 85852c5af7..2448d1c93f 100644 --- a/ui/desktop/src/api/index.ts +++ b/ui/desktop/src/api/index.ts @@ -1,4 +1,4 @@ // This file is auto-generated by @hey-api/openapi-ts export { addExtension, agentAddExtension, agentRemoveExtension, backupConfig, callTool, cancelDownload, cancelLocalModelDownload, checkProvider, configureProviderOauth, confirmToolAction, createCustomProvider, createRecipe, createSchedule, decodeRecipe, deleteLocalModel, deleteModel, deleteRecipe, deleteSchedule, deleteSession, detectProvider, diagnostics, downloadHfModel, downloadModel, encodeRecipe, exportApp, exportSession, forkSession, getCanonicalModelInfo, getCustomProvider, getDictationConfig, getDownloadProgress, getExtensions, getLocalModelDownloadProgress, getModelSettings, getPrompt, getPrompts, getProviderCatalog, getProviderCatalogTemplate, getProviderModels, getRepoFiles, getSession, getSessionExtensions, getSessionInsights, getSlashCommands, getTools, getTunnelStatus, importApp, importSession, initConfig, inspectRunningJob, killRunningJob, listApps, listLocalModels, listModels, listRecipes, listSchedules, listSessions, mcpUiProxy, type Options, parseRecipe, pauseSchedule, providers, readAllConfig, readConfig, readResource, recipeToYaml, recoverConfig, removeConfig, removeCustomProvider, removeExtension, reply, resetPrompt, restartAgent, resumeAgent, runNowHandler, savePrompt, saveRecipe, scanRecipe, scheduleRecipe, searchHfModels, searchSessions, sendTelemetryEvent, sessionsHandler, setConfigProvider, setRecipeSlashCommand, startAgent, startOpenrouterSetup, startTetrateSetup, startTunnel, status, stopAgent, stopTunnel, systemInfo, transcribeDictation, unpauseSchedule, updateAgentProvider, updateCustomProvider, updateFromSession, updateModelSettings, updateSchedule, updateSessionName, updateSessionUserRecipeValues, updateWorkingDir, upsertConfig, upsertPermissions, validateConfig } from './sdk.gen'; -export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, ChatRequest, CheckProviderData, CheckProviderRequest, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, DictationProvider, DictationProviderStatus, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponse, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelRequest, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponse, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponse, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponse, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponse, GetProviderCatalogTemplateResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponse, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, HfGgufFile, HfModelInfo, HfQuantVariant, Icon, ImageContent, ImageContentSchemaRef, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListLocalModelsData, ListLocalModelsResponse, ListLocalModelsResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, LocalModelResponse, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelCapabilities, ModelConfig, ModelDownloadStatus, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelSettings, ModelTemplate, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderCatalogEntry, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTemplate, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, ReasoningContent, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, RepoVariantsResponse, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RoleSchemaRef, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SamplingConfig, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponse, SearchHfModelsResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponse, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, TextContentSchemaRef, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponse, UpdateModelSettingsResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; +export type { ActionRequired, ActionRequiredData, AddExtensionData, AddExtensionErrors, AddExtensionRequest, AddExtensionResponse, AddExtensionResponses, AgentAddExtensionData, AgentAddExtensionErrors, AgentAddExtensionResponse, AgentAddExtensionResponses, AgentRemoveExtensionData, AgentRemoveExtensionErrors, AgentRemoveExtensionResponse, AgentRemoveExtensionResponses, Annotations, Author, AuthorRequest, BackupConfigData, BackupConfigErrors, BackupConfigResponse, BackupConfigResponses, CallToolData, CallToolErrors, CallToolRequest, CallToolResponse, CallToolResponse2, CallToolResponses, CancelDownloadData, CancelDownloadErrors, CancelDownloadResponses, CancelLocalModelDownloadData, CancelLocalModelDownloadErrors, CancelLocalModelDownloadResponses, ChatRequest, CheckProviderData, CheckProviderRequest, ClientOptions, CommandType, ConfigKey, ConfigKeyQuery, ConfigResponse, ConfigureProviderOauthData, ConfigureProviderOauthErrors, ConfigureProviderOauthResponses, ConfirmToolActionData, ConfirmToolActionErrors, ConfirmToolActionRequest, ConfirmToolActionResponses, Content, Conversation, CreateCustomProviderData, CreateCustomProviderErrors, CreateCustomProviderResponse, CreateCustomProviderResponses, CreateRecipeData, CreateRecipeErrors, CreateRecipeRequest, CreateRecipeResponse, CreateRecipeResponse2, CreateRecipeResponses, CreateScheduleData, CreateScheduleErrors, CreateScheduleRequest, CreateScheduleResponse, CreateScheduleResponses, CspMetadata, DeclarativeProviderConfig, DecodeRecipeData, DecodeRecipeErrors, DecodeRecipeRequest, DecodeRecipeResponse, DecodeRecipeResponse2, DecodeRecipeResponses, DeleteLocalModelData, DeleteLocalModelErrors, DeleteLocalModelResponses, DeleteModelData, DeleteModelErrors, DeleteModelResponses, DeleteRecipeData, DeleteRecipeErrors, DeleteRecipeRequest, DeleteRecipeResponse, DeleteRecipeResponses, DeleteScheduleData, DeleteScheduleErrors, DeleteScheduleResponse, DeleteScheduleResponses, DeleteSessionData, DeleteSessionErrors, DeleteSessionResponses, DetectProviderData, DetectProviderErrors, DetectProviderRequest, DetectProviderResponse, DetectProviderResponse2, DetectProviderResponses, DiagnosticsData, DiagnosticsErrors, DiagnosticsResponse, DiagnosticsResponses, DictationProvider, DictationProviderStatus, DownloadHfModelData, DownloadHfModelErrors, DownloadHfModelResponse, DownloadHfModelResponses, DownloadModelData, DownloadModelErrors, DownloadModelRequest, DownloadModelResponses, DownloadProgress, DownloadStatus, EmbeddedResource, EncodeRecipeData, EncodeRecipeErrors, EncodeRecipeRequest, EncodeRecipeResponse, EncodeRecipeResponse2, EncodeRecipeResponses, Envs, EnvVarConfig, ErrorResponse, ExportAppData, ExportAppError, ExportAppErrors, ExportAppResponse, ExportAppResponses, ExportSessionData, ExportSessionErrors, ExportSessionResponse, ExportSessionResponses, ExtensionConfig, ExtensionData, ExtensionEntry, ExtensionLoadResult, ExtensionQuery, ExtensionResponse, ForkRequest, ForkResponse, ForkSessionData, ForkSessionErrors, ForkSessionResponse, ForkSessionResponses, FrontendToolRequest, GetCanonicalModelInfoData, GetCanonicalModelInfoResponse, GetCanonicalModelInfoResponses, GetCustomProviderData, GetCustomProviderErrors, GetCustomProviderResponse, GetCustomProviderResponses, GetDictationConfigData, GetDictationConfigResponse, GetDictationConfigResponses, GetDownloadProgressData, GetDownloadProgressErrors, GetDownloadProgressResponse, GetDownloadProgressResponses, GetExtensionsData, GetExtensionsErrors, GetExtensionsResponse, GetExtensionsResponses, GetLocalModelDownloadProgressData, GetLocalModelDownloadProgressErrors, GetLocalModelDownloadProgressResponse, GetLocalModelDownloadProgressResponses, GetModelSettingsData, GetModelSettingsErrors, GetModelSettingsResponse, GetModelSettingsResponses, GetPromptData, GetPromptErrors, GetPromptResponse, GetPromptResponses, GetPromptsData, GetPromptsResponse, GetPromptsResponses, GetProviderCatalogData, GetProviderCatalogErrors, GetProviderCatalogResponse, GetProviderCatalogResponses, GetProviderCatalogTemplateData, GetProviderCatalogTemplateErrors, GetProviderCatalogTemplateResponse, GetProviderCatalogTemplateResponses, GetProviderModelsData, GetProviderModelsErrors, GetProviderModelsResponse, GetProviderModelsResponses, GetRepoFilesData, GetRepoFilesResponse, GetRepoFilesResponses, GetSessionData, GetSessionErrors, GetSessionExtensionsData, GetSessionExtensionsErrors, GetSessionExtensionsResponse, GetSessionExtensionsResponses, GetSessionInsightsData, GetSessionInsightsErrors, GetSessionInsightsResponse, GetSessionInsightsResponses, GetSessionResponse, GetSessionResponses, GetSlashCommandsData, GetSlashCommandsResponse, GetSlashCommandsResponses, GetToolsData, GetToolsErrors, GetToolsQuery, GetToolsResponse, GetToolsResponses, GetTunnelStatusData, GetTunnelStatusResponse, GetTunnelStatusResponses, GooseApp, HfGgufFile, HfModelInfo, HfQuantVariant, Icon, ImageContent, ImageContentSchemaRef, ImportAppData, ImportAppError, ImportAppErrors, ImportAppRequest, ImportAppResponse, ImportAppResponse2, ImportAppResponses, ImportSessionData, ImportSessionErrors, ImportSessionRequest, ImportSessionResponse, ImportSessionResponses, InitConfigData, InitConfigErrors, InitConfigResponse, InitConfigResponses, InspectJobResponse, InspectRunningJobData, InspectRunningJobErrors, InspectRunningJobResponse, InspectRunningJobResponses, JsonObject, KillJobResponse, KillRunningJobData, KillRunningJobResponses, ListAppsData, ListAppsError, ListAppsErrors, ListAppsRequest, ListAppsResponse, ListAppsResponse2, ListAppsResponses, ListLocalModelsData, ListLocalModelsResponse, ListLocalModelsResponses, ListModelsData, ListModelsResponse, ListModelsResponses, ListRecipeResponse, ListRecipesData, ListRecipesErrors, ListRecipesResponse, ListRecipesResponses, ListSchedulesData, ListSchedulesErrors, ListSchedulesResponse, ListSchedulesResponse2, ListSchedulesResponses, ListSessionsData, ListSessionsErrors, ListSessionsResponse, ListSessionsResponses, LoadedProvider, LocalModelResponse, McpAppResource, McpUiProxyData, McpUiProxyErrors, McpUiProxyResponses, Message, MessageContent, MessageEvent, MessageMetadata, ModelCapabilities, ModelConfig, ModelDownloadStatus, ModelInfo, ModelInfoData, ModelInfoQuery, ModelInfoResponse, ModelSettings, ModelTemplate, ParseRecipeData, ParseRecipeError, ParseRecipeErrors, ParseRecipeRequest, ParseRecipeResponse, ParseRecipeResponse2, ParseRecipeResponses, PauseScheduleData, PauseScheduleErrors, PauseScheduleResponse, PauseScheduleResponses, Permission, PermissionLevel, PermissionsMetadata, PrincipalType, PromptContentResponse, PromptsListResponse, ProviderCatalogEntry, ProviderDetails, ProviderEngine, ProviderMetadata, ProvidersData, ProvidersResponse, ProvidersResponse2, ProvidersResponses, ProviderTemplate, ProviderType, RawAudioContent, RawEmbeddedResource, RawImageContent, RawResource, RawTextContent, ReadAllConfigData, ReadAllConfigResponse, ReadAllConfigResponses, ReadConfigData, ReadConfigErrors, ReadConfigResponses, ReadResourceData, ReadResourceErrors, ReadResourceRequest, ReadResourceResponse, ReadResourceResponse2, ReadResourceResponses, ReasoningContent, Recipe, RecipeManifest, RecipeParameter, RecipeParameterInputType, RecipeParameterRequirement, RecipeToYamlData, RecipeToYamlError, RecipeToYamlErrors, RecipeToYamlRequest, RecipeToYamlResponse, RecipeToYamlResponse2, RecipeToYamlResponses, RecoverConfigData, RecoverConfigErrors, RecoverConfigResponse, RecoverConfigResponses, RedactedThinkingContent, RemoveConfigData, RemoveConfigErrors, RemoveConfigResponse, RemoveConfigResponses, RemoveCustomProviderData, RemoveCustomProviderErrors, RemoveCustomProviderResponse, RemoveCustomProviderResponses, RemoveExtensionData, RemoveExtensionErrors, RemoveExtensionRequest, RemoveExtensionResponse, RemoveExtensionResponses, ReplyData, ReplyErrors, ReplyResponse, ReplyResponses, RepoVariantsResponse, ResetPromptData, ResetPromptErrors, ResetPromptResponse, ResetPromptResponses, ResourceContents, ResourceMetadata, Response, RestartAgentData, RestartAgentErrors, RestartAgentRequest, RestartAgentResponse, RestartAgentResponse2, RestartAgentResponses, ResumeAgentData, ResumeAgentErrors, ResumeAgentRequest, ResumeAgentResponse, ResumeAgentResponse2, ResumeAgentResponses, RetryConfig, Role, RoleSchemaRef, RunNowHandlerData, RunNowHandlerErrors, RunNowHandlerResponse, RunNowHandlerResponses, RunNowResponse, SamplingConfig, SavePromptData, SavePromptErrors, SavePromptRequest, SavePromptResponse, SavePromptResponses, SaveRecipeData, SaveRecipeError, SaveRecipeErrors, SaveRecipeRequest, SaveRecipeResponse, SaveRecipeResponse2, SaveRecipeResponses, ScanRecipeData, ScanRecipeRequest, ScanRecipeResponse, ScanRecipeResponse2, ScanRecipeResponses, ScheduledJob, ScheduleRecipeData, ScheduleRecipeErrors, ScheduleRecipeRequest, ScheduleRecipeResponses, SearchHfModelsData, SearchHfModelsErrors, SearchHfModelsResponse, SearchHfModelsResponses, SearchSessionsData, SearchSessionsErrors, SearchSessionsResponse, SearchSessionsResponses, SendTelemetryEventData, SendTelemetryEventResponses, Session, SessionDisplayInfo, SessionExtensionsResponse, SessionInsights, SessionListResponse, SessionsHandlerData, SessionsHandlerErrors, SessionsHandlerResponse, SessionsHandlerResponses, SessionsQuery, SessionType, SetConfigProviderData, SetProviderRequest, SetRecipeSlashCommandData, SetRecipeSlashCommandErrors, SetRecipeSlashCommandResponses, SetSlashCommandRequest, Settings, SetupResponse, SlashCommand, SlashCommandsResponse, StartAgentData, StartAgentError, StartAgentErrors, StartAgentRequest, StartAgentResponse, StartAgentResponses, StartOpenrouterSetupData, StartOpenrouterSetupResponse, StartOpenrouterSetupResponses, StartTetrateSetupData, StartTetrateSetupResponse, StartTetrateSetupResponses, StartTunnelData, StartTunnelError, StartTunnelErrors, StartTunnelResponse, StartTunnelResponses, StatusData, StatusResponse, StatusResponses, StopAgentData, StopAgentErrors, StopAgentRequest, StopAgentResponse, StopAgentResponses, StopTunnelData, StopTunnelError, StopTunnelErrors, StopTunnelResponses, SubRecipe, SuccessCheck, SystemInfo, SystemInfoData, SystemInfoResponse, SystemInfoResponses, SystemNotificationContent, SystemNotificationType, TaskSupport, TelemetryEventRequest, Template, TextContent, TextContentSchemaRef, ThinkingContent, TokenState, Tool, ToolAnnotations, ToolConfirmationRequest, ToolExecution, ToolInfo, ToolPermission, ToolRequest, ToolResponse, TranscribeDictationData, TranscribeDictationErrors, TranscribeDictationResponse, TranscribeDictationResponses, TranscribeRequest, TranscribeResponse, TunnelInfo, TunnelState, UiMetadata, UnpauseScheduleData, UnpauseScheduleErrors, UnpauseScheduleResponse, UnpauseScheduleResponses, UpdateAgentProviderData, UpdateAgentProviderErrors, UpdateAgentProviderResponses, UpdateCustomProviderData, UpdateCustomProviderErrors, UpdateCustomProviderRequest, UpdateCustomProviderResponse, UpdateCustomProviderResponses, UpdateFromSessionData, UpdateFromSessionErrors, UpdateFromSessionRequest, UpdateFromSessionResponses, UpdateModelSettingsData, UpdateModelSettingsErrors, UpdateModelSettingsResponse, UpdateModelSettingsResponses, UpdateProviderRequest, UpdateScheduleData, UpdateScheduleErrors, UpdateScheduleRequest, UpdateScheduleResponse, UpdateScheduleResponses, UpdateSessionNameData, UpdateSessionNameErrors, UpdateSessionNameRequest, UpdateSessionNameResponses, UpdateSessionUserRecipeValuesData, UpdateSessionUserRecipeValuesError, UpdateSessionUserRecipeValuesErrors, UpdateSessionUserRecipeValuesRequest, UpdateSessionUserRecipeValuesResponse, UpdateSessionUserRecipeValuesResponse2, UpdateSessionUserRecipeValuesResponses, UpdateWorkingDirData, UpdateWorkingDirErrors, UpdateWorkingDirRequest, UpdateWorkingDirResponses, UpsertConfigData, UpsertConfigErrors, UpsertConfigQuery, UpsertConfigResponse, UpsertConfigResponses, UpsertPermissionsData, UpsertPermissionsErrors, UpsertPermissionsQuery, UpsertPermissionsResponse, UpsertPermissionsResponses, ValidateConfigData, ValidateConfigErrors, ValidateConfigResponse, ValidateConfigResponses, WhisperModelResponse, WindowProps } from './types.gen'; diff --git a/ui/desktop/src/api/types.gen.ts b/ui/desktop/src/api/types.gen.ts index d6fb7887c7..75d4d02e0c 100644 --- a/ui/desktop/src/api/types.gen.ts +++ b/ui/desktop/src/api/types.gen.ts @@ -178,7 +178,9 @@ export type DeclarativeProviderConfig = { catalog_provider_id?: string | null; description?: string | null; display_name: string; + dynamic_models?: boolean | null; engine: ProviderEngine; + env_vars?: Array | null; headers?: { [key: string]: string; } | null; @@ -301,6 +303,14 @@ export type EncodeRecipeResponse = { deeplink: string; }; +export type EnvVarConfig = { + default?: string | null; + description?: string | null; + name: string; + required?: boolean; + secret?: boolean; +}; + export type Envs = { [key: string]: string; }; diff --git a/ui/desktop/src/bin/jbang b/ui/desktop/src/bin/jbang index d06edbf61b..5456939a1a 100755 --- a/ui/desktop/src/bin/jbang +++ b/ui/desktop/src/bin/jbang @@ -20,31 +20,41 @@ trap 'log "An error occurred. Exiting with status $?."' ERR log "Starting jbang setup script." -# Ensure ~/.config/goose/mcp-hermit/bin exists -log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist." -mkdir -p ~/.config/goose/mcp-hermit/bin +if [ -n "${GOOSE_PATH_ROOT:-}" ]; then + RESOLVED_GOOSE_CONFIG_DIR="${GOOSE_PATH_ROOT}/config" +elif [ -n "${GOOSE_CONFIG_DIR:-}" ]; then + log "GOOSE_CONFIG_DIR is deprecated for desktop shims; prefer GOOSE_PATH_ROOT." + RESOLVED_GOOSE_CONFIG_DIR="${GOOSE_CONFIG_DIR}" +else + RESOLVED_GOOSE_CONFIG_DIR="${HOME}/.config/goose" +fi +MCP_HERMIT_DIR="${RESOLVED_GOOSE_CONFIG_DIR}/mcp-hermit" -# Change to the ~/.config/goose/mcp-hermit directory -log "Changing to directory ~/.config/goose/mcp-hermit." -cd ~/.config/goose/mcp-hermit +# Ensure mcp-hermit/bin exists +log "Creating directory ${MCP_HERMIT_DIR}/bin if it does not exist." +mkdir -p "${MCP_HERMIT_DIR}/bin" + +# Change to the mcp-hermit directory +log "Changing to directory ${MCP_HERMIT_DIR}." +cd "${MCP_HERMIT_DIR}" # Check if hermit binary exists and download if not -if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then +if [ ! -f "${MCP_HERMIT_DIR}/bin/hermit" ]; then log "Hermit binary not found. Downloading hermit binary." curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \ - | gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit + | gzip -dc > "${MCP_HERMIT_DIR}/bin/hermit" && chmod +x "${MCP_HERMIT_DIR}/bin/hermit" log "Hermit binary downloaded and made executable." else log "Hermit binary already exists. Skipping download." fi log "setting hermit cache to be local for MCP servers" -mkdir -p ~/.config/goose/mcp-hermit/cache -export HERMIT_STATE_DIR=~/.config/goose/mcp-hermit/cache +mkdir -p "${MCP_HERMIT_DIR}/cache" +export HERMIT_STATE_DIR="${MCP_HERMIT_DIR}/cache" # Update PATH -export PATH=~/.config/goose/mcp-hermit/bin:$PATH -log "Updated PATH to include ~/.config/goose/mcp-hermit/bin." +export PATH="${MCP_HERMIT_DIR}/bin:${PATH}" +log "Updated PATH to include ${MCP_HERMIT_DIR}/bin." # Initialize hermit log "Initializing hermit." @@ -55,10 +65,10 @@ log "Installing OpenJDK with hermit." hermit install openjdk@17 >> "$LOG_FILE" # Download and install jbang if not present -if [ ! -f ~/.config/goose/mcp-hermit/bin/jbang ]; then +if [ ! -f "${MCP_HERMIT_DIR}/bin/jbang" ]; then log "Downloading and installing jbang." curl -Ls https://sh.jbang.dev | bash -s - app setup - cp ~/.jbang/bin/jbang ~/.config/goose/mcp-hermit/bin/ + cp ~/.jbang/bin/jbang "${MCP_HERMIT_DIR}/bin/" fi # Verify installations @@ -86,4 +96,4 @@ jbang --quiet trust add * log "Executing 'jbang' command with arguments: $*" jbang --fresh --quiet "$@" || log "Failed to execute 'jbang' with arguments: $*" -log "jbang setup script completed successfully." \ No newline at end of file +log "jbang setup script completed successfully." diff --git a/ui/desktop/src/bin/node-setup-common.sh b/ui/desktop/src/bin/node-setup-common.sh index 34353034ea..748dd63d49 100755 --- a/ui/desktop/src/bin/node-setup-common.sh +++ b/ui/desktop/src/bin/node-setup-common.sh @@ -23,32 +23,43 @@ trap 'log "An error occurred. Exiting with status $?."' ERR log "Starting node setup (common)." +if [ -n "${GOOSE_PATH_ROOT:-}" ]; then + RESOLVED_GOOSE_CONFIG_DIR="${GOOSE_PATH_ROOT}/config" +elif [ -n "${GOOSE_CONFIG_DIR:-}" ]; then + log "GOOSE_CONFIG_DIR is deprecated for desktop shims; prefer GOOSE_PATH_ROOT." + RESOLVED_GOOSE_CONFIG_DIR="${GOOSE_CONFIG_DIR}" +else + RESOLVED_GOOSE_CONFIG_DIR="${HOME}/.config/goose" +fi +MCP_HERMIT_DIR="${RESOLVED_GOOSE_CONFIG_DIR}/mcp-hermit" + # One-time cleanup for existing Linux users to fix locking issues -CLEANUP_MARKER="${HOME}/.config/goose/.mcp-hermit-cleanup-v1" +CLEANUP_MARKER="${RESOLVED_GOOSE_CONFIG_DIR}/.mcp-hermit-cleanup-v1" if [[ "$(uname -s)" == "Linux" ]] && [ ! -f "${CLEANUP_MARKER}" ]; then log "Performing one-time cleanup of old mcp-hermit directory to fix locking issues." - if [ -d "${HOME}/.config/goose/mcp-hermit" ]; then - rm -rf "${HOME}/.config/goose/mcp-hermit" + if [ -d "${MCP_HERMIT_DIR}" ]; then + rm -rf "${MCP_HERMIT_DIR}" log "Removed old mcp-hermit directory." fi + mkdir -p "${RESOLVED_GOOSE_CONFIG_DIR}" touch "${CLEANUP_MARKER}" log "Cleanup completed. Marker file created." fi -# Ensure ${HOME}/.config/goose/mcp-hermit/bin exists -log "Creating directory ${HOME}/.config/goose/mcp-hermit/bin if it does not exist." -mkdir -p "${HOME}/.config/goose/mcp-hermit/bin" +# Ensure mcp-hermit/bin exists +log "Creating directory ${MCP_HERMIT_DIR}/bin if it does not exist." +mkdir -p "${MCP_HERMIT_DIR}/bin" -# Change to the ${HOME}/.config/goose/mcp-hermit directory -log "Changing to directory ${HOME}/.config/goose/mcp-hermit." -cd "${HOME}/.config/goose/mcp-hermit" +# Change to the mcp-hermit directory +log "Changing to directory ${MCP_HERMIT_DIR}." +cd "${MCP_HERMIT_DIR}" # Check if hermit binary exists and download if not -if [ ! -f "${HOME}/.config/goose/mcp-hermit/bin/hermit" ]; then +if [ ! -f "${MCP_HERMIT_DIR}/bin/hermit" ]; then log "Hermit binary not found. Downloading hermit binary." curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \ - | gzip -dc > "${HOME}/.config/goose/mcp-hermit/bin/hermit" && chmod +x "${HOME}/.config/goose/mcp-hermit/bin/hermit" + | gzip -dc > "${MCP_HERMIT_DIR}/bin/hermit" && chmod +x "${MCP_HERMIT_DIR}/bin/hermit" log "Hermit binary downloaded and made executable." else log "Hermit binary already exists. Skipping download." @@ -56,13 +67,13 @@ fi log "setting hermit cache to be local for MCP servers" -mkdir -p "${HOME}/.config/goose/mcp-hermit/cache" -export HERMIT_STATE_DIR="${HOME}/.config/goose/mcp-hermit/cache" +mkdir -p "${MCP_HERMIT_DIR}/cache" +export HERMIT_STATE_DIR="${MCP_HERMIT_DIR}/cache" # Update PATH -export PATH="${HOME}/.config/goose/mcp-hermit/bin:${PATH}" -log "Updated PATH to include ${HOME}/.config/goose/mcp-hermit/bin." +export PATH="${MCP_HERMIT_DIR}/bin:${PATH}" +log "Updated PATH to include ${MCP_HERMIT_DIR}/bin." # Verify hermit installation @@ -78,7 +89,7 @@ if [ ! -f "bin/activate-hermit" ]; then log "Creating temp dir with bin subdirectory for hermit copy to avoid self-update locks." HERMIT_TMP_DIR="/tmp/hermit_tmp_$$/bin" mkdir -p "${HERMIT_TMP_DIR}" - cp "${HOME}/.config/goose/mcp-hermit/bin/hermit" "${HERMIT_TMP_DIR}/hermit" + cp "${MCP_HERMIT_DIR}/bin/hermit" "${HERMIT_TMP_DIR}/hermit" chmod +x "${HERMIT_TMP_DIR}/hermit" export PATH="${HERMIT_TMP_DIR}:${PATH}" HERMIT_CLEANUP_DIR="/tmp/hermit_tmp_$$" @@ -124,10 +135,10 @@ if [ -n "${GOOSE_NPM_REGISTRY:-}" ] && curl -s --head --fail "${GOOSE_NPM_REGIST # Check if GOOSE_NPM_CERT is set and accessible if [ -n "${GOOSE_NPM_CERT:-}" ] && curl -s --head --fail "${GOOSE_NPM_CERT}" > /dev/null; then log "Downloading certificate from: ${GOOSE_NPM_CERT}" - curl -sSL -o "${HOME}/.config/goose/mcp-hermit/cert.pem" "${GOOSE_NPM_CERT}" + curl -sSL -o "${MCP_HERMIT_DIR}/cert.pem" "${GOOSE_NPM_CERT}" if [ $? -eq 0 ]; then log "Certificate downloaded successfully." - export NODE_EXTRA_CA_CERTS="${HOME}/.config/goose/mcp-hermit/cert.pem" + export NODE_EXTRA_CA_CERTS="${MCP_HERMIT_DIR}/cert.pem" else log "Unable to download the certificate. Skipping certificate setup." fi diff --git a/ui/desktop/src/bin/uvx b/ui/desktop/src/bin/uvx index b0f1bdbed6..369adadaa9 100755 --- a/ui/desktop/src/bin/uvx +++ b/ui/desktop/src/bin/uvx @@ -20,19 +20,29 @@ trap 'log "An error occurred. Exiting with status $?."' ERR log "Starting uvx setup script." -# Ensure ~/.config/goose/mcp-hermit/bin exists -log "Creating directory ~/.config/goose/mcp-hermit/bin if it does not exist." -mkdir -p ~/.config/goose/mcp-hermit/bin +if [ -n "${GOOSE_PATH_ROOT:-}" ]; then + RESOLVED_GOOSE_CONFIG_DIR="${GOOSE_PATH_ROOT}/config" +elif [ -n "${GOOSE_CONFIG_DIR:-}" ]; then + log "GOOSE_CONFIG_DIR is deprecated for desktop shims; prefer GOOSE_PATH_ROOT." + RESOLVED_GOOSE_CONFIG_DIR="${GOOSE_CONFIG_DIR}" +else + RESOLVED_GOOSE_CONFIG_DIR="${HOME}/.config/goose" +fi +MCP_HERMIT_DIR="${RESOLVED_GOOSE_CONFIG_DIR}/mcp-hermit" -# Change to the ~/.config/goose/mcp-hermit directory -log "Changing to directory ~/.config/goose/mcp-hermit." -cd ~/.config/goose/mcp-hermit +# Ensure mcp-hermit/bin exists +log "Creating directory ${MCP_HERMIT_DIR}/bin if it does not exist." +mkdir -p "${MCP_HERMIT_DIR}/bin" + +# Change to the mcp-hermit directory +log "Changing to directory ${MCP_HERMIT_DIR}." +cd "${MCP_HERMIT_DIR}" # Check if hermit binary exists and download if not -if [ ! -f ~/.config/goose/mcp-hermit/bin/hermit ]; then +if [ ! -f "${MCP_HERMIT_DIR}/bin/hermit" ]; then log "Hermit binary not found. Downloading hermit binary." curl -fsSL "https://github.com/cashapp/hermit/releases/download/stable/hermit-$(uname -s | tr '[:upper:]' '[:lower:]')-$(uname -m | sed 's/x86_64/amd64/' | sed 's/aarch64/arm64/').gz" \ - | gzip -dc > ~/.config/goose/mcp-hermit/bin/hermit && chmod +x ~/.config/goose/mcp-hermit/bin/hermit + | gzip -dc > "${MCP_HERMIT_DIR}/bin/hermit" && chmod +x "${MCP_HERMIT_DIR}/bin/hermit" log "Hermit binary downloaded and made executable." else log "Hermit binary already exists. Skipping download." @@ -40,12 +50,12 @@ fi log "setting hermit cache to be local for MCP servers" -mkdir -p ~/.config/goose/mcp-hermit/cache -export HERMIT_STATE_DIR=~/.config/goose/mcp-hermit/cache +mkdir -p "${MCP_HERMIT_DIR}/cache" +export HERMIT_STATE_DIR="${MCP_HERMIT_DIR}/cache" # Update PATH -export PATH=~/.config/goose/mcp-hermit/bin:$PATH -log "Updated PATH to include ~/.config/goose/mcp-hermit/bin." +export PATH="${MCP_HERMIT_DIR}/bin:${PATH}" +log "Updated PATH to include ${MCP_HERMIT_DIR}/bin." # Verify hermit installation diff --git a/ui/desktop/src/goosed.ts b/ui/desktop/src/goosed.ts index b0a3d96a1d..3fb6df24fd 100644 --- a/ui/desktop/src/goosed.ts +++ b/ui/desktop/src/goosed.ts @@ -7,7 +7,6 @@ import { Buffer } from 'node:buffer'; import { status } from './api'; import { Client, createClient, createConfig } from './api/client'; - export interface Logger { info: (...args: unknown[]) => void; error: (...args: unknown[]) => void; diff --git a/ui/desktop/src/main.ts b/ui/desktop/src/main.ts index bfba32cb8d..efa8ba42d1 100644 --- a/ui/desktop/src/main.ts +++ b/ui/desktop/src/main.ts @@ -496,6 +496,14 @@ const getBundledConfig = (): BundledConfig => { const { defaultProvider, defaultModel, predefinedModels, baseUrlShare, version } = getBundledConfig(); +const resolveGoosePathRoot = (): string | undefined => { + const pathRoot = process.env.GOOSE_PATH_ROOT?.trim(); + if (pathRoot) { + return expandTilde(pathRoot); + } + return undefined; +}; + const GENERATED_SECRET = crypto.randomBytes(32).toString('hex'); const getServerSecret = (settings: Settings): string => { @@ -513,6 +521,7 @@ let appConfig = { GOOSE_DEFAULT_MODEL: defaultModel, GOOSE_PREDEFINED_MODELS: predefinedModels, GOOSE_API_HOST: 'https://localhost', + GOOSE_PATH_ROOT: resolveGoosePathRoot(), GOOSE_WORKING_DIR: '', // If GOOSE_ALLOWLIST_WARNING env var is not set, defaults to false (strict blocking mode) GOOSE_ALLOWLIST_WARNING: process.env.GOOSE_ALLOWLIST_WARNING === 'true', @@ -554,7 +563,9 @@ const createChat = async (app: App, options: CreateChatOptions = {}) => { const goosedResult = await startGoosed({ serverSecret, dir: dir || os.homedir(), - env: { GOOSE_PATH_ROOT: process.env.GOOSE_PATH_ROOT }, + env: { + GOOSE_PATH_ROOT: appConfig.GOOSE_PATH_ROOT as string | undefined, + }, externalGoosed: settings.externalGoosed, isPackaged: app.isPackaged, resourcesPath: app.isPackaged ? process.resourcesPath : undefined, diff --git a/ui/desktop/src/recipe/recipe_management.ts b/ui/desktop/src/recipe/recipe_management.ts index 75cfc5a104..3853a14d9d 100644 --- a/ui/desktop/src/recipe/recipe_management.ts +++ b/ui/desktop/src/recipe/recipe_management.ts @@ -43,6 +43,14 @@ export const convertToLocaleDateString = (lastModified: string): string => { export const getStorageDirectory = (isGlobal: boolean): string => { if (isGlobal) { + const pathRoot = window.appConfig.get('GOOSE_PATH_ROOT') as string | undefined; + if (pathRoot) { + return `${pathRoot}/config/recipes`; + } + const configDir = window.appConfig.get('GOOSE_CONFIG_DIR') as string | undefined; + if (configDir) { + return `${configDir}/recipes`; + } return '~/.config/goose/recipes'; } else { // For directory recipes, build absolute path using working directory diff --git a/ui/desktop/src/toasts.tsx b/ui/desktop/src/toasts.tsx index 6dd04f0c05..139ade94c1 100644 --- a/ui/desktop/src/toasts.tsx +++ b/ui/desktop/src/toasts.tsx @@ -83,6 +83,8 @@ class ToastService { } const toastId = 'extension-loading'; + const hasErrors = extensions.some((ext) => ext.status === 'error'); + const autoClose = isComplete && !hasErrors ? 5000 : false; // Check if toast already exists if (toast.isActive(toastId)) { @@ -95,7 +97,7 @@ class ToastService { isComplete={isComplete} /> ), - autoClose: isComplete ? 5000 : false, + autoClose, closeButton: true, closeOnClick: false, }); @@ -110,7 +112,7 @@ class ToastService { { ...commonToastOptions, toastId, - autoClose: isComplete ? 5000 : false, + autoClose, closeButton: true, closeOnClick: false, // Prevent closing when clicking to expand/collapse }