Merge remote-tracking branch 'origin/main' into jhugo/bump-workspace-deps

Resolve conflict in generated ui/desktop/src/api/index.ts by
regenerating OpenAPI spec and TS types. Also ran cargo update.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
jh-block
2026-03-10 16:33:36 +01:00
26 changed files with 906 additions and 140 deletions
Generated
+23 -19
View File
@@ -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",
+2 -1
View File
@@ -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,
+1
View File
@@ -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 }
@@ -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]
@@ -169,6 +169,14 @@ fn parse_frontmatter<T: for<'de> Deserialize<'de>>(content: &str) -> Option<(T,
fn parse_skill_content(content: &str, path: PathBuf) -> Option<Source> {
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<Source> {
) -> Result<Option<Source>, 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<Vec<Content>, 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: \"<skill-name>/<path>\") 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<String> = 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<Recipe, String> {
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: \"<skill-name>/<path>\")"),
"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();
+113 -4
View File
@@ -459,20 +459,57 @@ impl Config {
load_init_config_from_workspace()
}
fn config_write_target_path(&self) -> Result<PathBuf, ConfigError> {
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<Mapping, ConfigError> {
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();
@@ -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<String>,
pub default: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
pub struct DeclarativeProviderConfig {
pub name: String,
@@ -46,6 +57,10 @@ pub struct DeclarativeProviderConfig {
pub catalog_provider_id: Option<String>,
#[serde(default)]
pub base_path: Option<String>,
#[serde(default)]
pub env_vars: Option<Vec<EnvVarConfig>>,
#[serde(default)]
pub dynamic_models: Option<bool>,
}
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<String> {
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::<String>(&var.name).ok()
} else {
config.get_param::<String>(&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");
}
}
+1 -22
View File
@@ -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");
}
}
+14 -2
View File
@@ -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<String> = 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?;
@@ -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
}
+37
View File
@@ -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;
@@ -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(),
+2 -2
View File
@@ -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:
goose-config:
@@ -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) |
+44
View File
@@ -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": {
+1 -27
View File
@@ -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",
+2 -3
View File
@@ -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);
File diff suppressed because one or more lines are too long
+10
View File
@@ -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<EnvVarConfig> | 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;
};
+25 -15
View File
@@ -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."
log "jbang setup script completed successfully."
+29 -18
View File
@@ -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
+22 -12
View File
@@ -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
-1
View File
@@ -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;
+12 -1
View File
@@ -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,
@@ -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
+4 -2
View File
@@ -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
}