Skip to content

Commit a7f61c6

Browse files
committed
fix: allow legacy custom provider IDs
Custom provider read, update, and delete paths now permit legacy IDs with punctuation, such as `custom_z.ai`, while preserving strict ID validation for newly generated providers. The file lookup path still rejects empty IDs, path separators, and control characters so legacy compatibility does not reintroduce unsafe filesystem access. ACP also defers to the core loader instead of pre-rejecting these legacy IDs.
1 parent 9be95d5 commit a7f61c6

2 files changed

Lines changed: 92 additions & 11 deletions

File tree

crates/goose/src/acp/server.rs

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -842,9 +842,6 @@ fn custom_provider_headers(headers: HashMap<String, String>) -> Option<HashMap<S
842842
fn load_declarative_provider_for_client(
843843
provider_id: &str,
844844
) -> Result<declarative_providers::LoadedProvider, sacp::Error> {
845-
declarative_providers::validate_provider_id(provider_id)
846-
.map_err(|error| sacp::Error::invalid_params().data(error.to_string()))?;
847-
848845
declarative_providers::load_provider(provider_id).map_err(|error| {
849846
if error.to_string().contains("Provider not found") {
850847
sacp::Error::invalid_params().data(format!("Unknown provider: {provider_id}"))

crates/goose/src/config/declarative_providers.rs

Lines changed: 92 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ where
2020
Ok(opt.filter(|s| !s.trim().is_empty()))
2121
}
2222
use std::collections::HashMap;
23-
use std::path::Path;
23+
use std::path::{Path, PathBuf};
2424
use std::sync::Mutex;
2525
use utoipa::ToSchema;
2626

@@ -207,6 +207,21 @@ pub fn validate_provider_id(id: &str) -> Result<()> {
207207
}
208208
}
209209

210+
fn custom_provider_file_path(id: &str) -> Result<PathBuf> {
211+
if id.is_empty()
212+
|| id
213+
.chars()
214+
.any(|ch| ch == '/' || ch == '\\' || ch.is_control())
215+
{
216+
return Err(anyhow::anyhow!(
217+
"Invalid provider id: {}",
218+
if id.is_empty() { "<empty>" } else { id }
219+
));
220+
}
221+
222+
Ok(custom_providers_dir().join(format!("{}.json", id)))
223+
}
224+
210225
pub fn generate_api_key_name(id: &str) -> String {
211226
format!("{}_API_KEY", id.to_uppercase())
212227
}
@@ -299,7 +314,6 @@ pub fn create_custom_provider(
299314
}
300315

301316
pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()> {
302-
validate_provider_id(&params.id)?;
303317
let loaded_provider = load_provider(&params.id)?;
304318
let existing_config = loaded_provider.config;
305319
let editable = loaded_provider.is_editable;
@@ -359,24 +373,22 @@ pub fn update_custom_provider(params: UpdateCustomProviderParams) -> Result<()>
359373
fast_model: existing_config.fast_model.clone(),
360374
};
361375

362-
let file_path = custom_providers_dir().join(format!("{}.json", updated_config.name));
376+
let file_path = custom_provider_file_path(&updated_config.name)?;
363377
let json_content = serde_json::to_string_pretty(&updated_config)?;
364378
std::fs::write(file_path, json_content)?;
365379
}
366380
Ok(())
367381
}
368382

369383
pub fn remove_custom_provider(id: &str) -> Result<()> {
370-
validate_provider_id(id)?;
371384
let config = Config::global();
372385
let loaded_provider = load_provider(id)?;
373386
let api_key_env = loaded_provider.config.api_key_env;
374387
if api_key_env == generate_api_key_name(id) {
375388
let _ = config.delete_secret(&api_key_env);
376389
}
377390

378-
let custom_providers_dir = custom_providers_dir();
379-
let file_path = custom_providers_dir.join(format!("{}.json", id));
391+
let file_path = custom_provider_file_path(id)?;
380392

381393
if file_path.exists() {
382394
std::fs::remove_file(file_path)?;
@@ -386,8 +398,7 @@ pub fn remove_custom_provider(id: &str) -> Result<()> {
386398
}
387399

388400
pub fn load_provider(id: &str) -> Result<LoadedProvider> {
389-
validate_provider_id(id)?;
390-
let custom_file_path = custom_providers_dir().join(format!("{}.json", id));
401+
let custom_file_path = custom_provider_file_path(id)?;
391402

392403
if custom_file_path.exists() {
393404
let content = std::fs::read_to_string(&custom_file_path)?;
@@ -677,6 +688,79 @@ mod tests {
677688
assert_eq!(config.models[0].context_limit, 131072);
678689
}
679690

691+
#[test]
692+
fn test_validate_provider_id_rejects_legacy_punctuation_for_new_ids() {
693+
assert!(validate_provider_id("custom_z.ai").is_err());
694+
}
695+
696+
fn write_legacy_provider_config(id: &str, display_name: &str) {
697+
let custom_dir = custom_providers_dir();
698+
std::fs::create_dir_all(&custom_dir).unwrap();
699+
let content = format!(
700+
r#"{{
701+
"name": "{id}",
702+
"engine": "openai",
703+
"display_name": "{display_name}",
704+
"description": "legacy provider",
705+
"api_key_env": "",
706+
"base_url": "https://example.invalid/v1/chat/completions",
707+
"models": [],
708+
"requires_auth": false
709+
}}"#
710+
);
711+
std::fs::write(custom_dir.join(format!("{id}.json")), content).unwrap();
712+
}
713+
714+
#[test]
715+
fn test_load_provider_allows_legacy_custom_id_with_punctuation() {
716+
let temp_dir = tempfile::tempdir().unwrap();
717+
let temp_root = temp_dir.path().display().to_string();
718+
let _guard = env_lock::lock_env([("GOOSE_PATH_ROOT", Some(temp_root.as_str()))]);
719+
720+
write_legacy_provider_config("custom_z.ai", "Z.AI");
721+
722+
let loaded = load_provider("custom_z.ai").unwrap();
723+
assert!(loaded.is_editable);
724+
assert_eq!(loaded.config.name, "custom_z.ai");
725+
}
726+
727+
#[test]
728+
fn test_update_and_remove_provider_allow_legacy_custom_id_with_punctuation() {
729+
let temp_dir = tempfile::tempdir().unwrap();
730+
let temp_root = temp_dir.path().display().to_string();
731+
let _guard = env_lock::lock_env([("GOOSE_PATH_ROOT", Some(temp_root.as_str()))]);
732+
733+
write_legacy_provider_config("custom_z.ai", "Z.AI");
734+
735+
update_custom_provider(UpdateCustomProviderParams {
736+
id: "custom_z.ai".to_string(),
737+
engine: "openai".to_string(),
738+
display_name: "Z.AI Updated".to_string(),
739+
api_url: "https://updated.example.invalid/v1/chat/completions".to_string(),
740+
api_key: None,
741+
models: vec!["z-model".to_string()],
742+
supports_streaming: Some(true),
743+
headers: None,
744+
requires_auth: false,
745+
catalog_provider_id: None,
746+
base_path: None,
747+
})
748+
.unwrap();
749+
750+
let updated = load_provider("custom_z.ai").unwrap();
751+
assert_eq!(updated.config.display_name, "Z.AI Updated");
752+
assert_eq!(updated.config.models[0].name, "z-model");
753+
754+
remove_custom_provider("custom_z.ai").unwrap();
755+
assert!(!custom_providers_dir().join("custom_z.ai.json").exists());
756+
}
757+
758+
#[test]
759+
fn test_load_provider_rejects_path_segments() {
760+
assert!(load_provider("custom_../secret").is_err());
761+
assert!(load_provider("custom_..\\secret").is_err());
762+
}
763+
680764
#[test]
681765
fn test_expand_env_vars_replaces_placeholder() {
682766
let _guard = env_lock::lock_env([("TEST_EXPAND_HOST", Some("https://example.com/api"))]);

0 commit comments

Comments
 (0)