mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
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:
Generated
+23
-19
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Generated
+1
-27
@@ -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",
|
||||
|
||||
@@ -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
@@ -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
@@ -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."
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user