#[allow(dead_code)] #[path = "acp_common_tests/mod.rs"] mod common_tests; use common_tests::fixtures::server::AcpServerConnection; use common_tests::fixtures::{run_test, send_custom, Connection, TestConnectionConfig}; use goose::config::paths::Paths; use goose::config::{Config, ConfigError}; use goose::model::ModelConfig; use goose::providers::base::{MessageStream, Provider}; use goose::providers::inventory::ProviderInventoryService; use goose::session::session_manager::SessionStorage; use goose_providers::errors::ProviderError; use goose_test_support::EnforceSessionId; use serial_test::serial; use std::sync::Arc; struct MockProvider { name: String, model_config: ModelConfig, } #[async_trait::async_trait] impl Provider for MockProvider { fn get_name(&self) -> &str { &self.name } async fn stream( &self, _model_config: &ModelConfig, _session_id: &str, _system: &str, _messages: &[goose::conversation::message::Message], _tools: &[rmcp::model::Tool], ) -> Result { unimplemented!() } fn get_model_config(&self) -> ModelConfig { self.model_config.clone() } async fn fetch_recommended_models(&self) -> Result, ProviderError> { Ok(vec!["claude-3-5-haiku-latest".to_string()]) } } fn mock_provider_factory() -> goose::acp::server::AcpProviderFactory { Arc::new(|provider_name, model_config, _extensions, _working_dir| { Box::pin(async move { Ok(Arc::new(MockProvider { name: provider_name, model_config, }) as Arc) }) }) } fn write_config(config_dir: &std::path::Path) { std::fs::create_dir_all(config_dir).unwrap(); std::fs::write( config_dir.join(goose::config::base::CONFIG_YAML_NAME), "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_DISABLE_KEYRING: true\n", ) .unwrap(); } fn write_secrets(config_dir: &std::path::Path, secrets: &str) { std::fs::write(config_dir.join("secrets.yaml"), secrets).unwrap(); } #[test] #[serial] fn acp_secret_mutations_and_inventory_refresh_invalidate_global_secret_cache() { let root = tempfile::tempdir().unwrap(); let root_path = root.path().to_string_lossy().to_string(); let _env = env_lock::lock_env([ ("GOOSE_PATH_ROOT", Some(root_path.as_str())), ("GOOSE_DISABLE_KEYRING", Some("1")), ("ANTHROPIC_API_KEY", None), ("GROQ_API_KEY", None), ("OPENAI_API_KEY", None), ("XAI_API_KEY", None), ("XAI_HOST", None), ]); let config_dir = Paths::config_dir(); let data_dir = Paths::data_dir(); write_config(&config_dir); write_secrets(&config_dir, "GROQ_API_KEY: stale-key\n"); run_test(async move { assert_eq!( Config::global() .get_secret::("GROQ_API_KEY") .unwrap(), "stale-key" ); let openai = common_tests::fixtures::OpenAiFixture::new( vec![], Arc::new(EnforceSessionId::default()), ) .await; let config = TestConnectionConfig { data_root: config_dir.clone(), provider_factory: Some(mock_provider_factory()), ..Default::default() }; let conn = AcpServerConnection::new(config, openai).await; write_secrets(&config_dir, "GROQ_API_KEY: fresh-key\n"); send_custom( conn.cx(), "_goose/unstable/dictation/secret/save", serde_json::json!({ "provider": "groq", "value": "fresh-key", }), ) .await .expect("dictation secret save should succeed"); assert_eq!( Config::global() .get_secret::("GROQ_API_KEY") .unwrap(), "fresh-key", "ACP dictation secret save should invalidate the global secrets cache" ); write_secrets(&config_dir, "{}\n"); send_custom( conn.cx(), "_goose/unstable/dictation/secret/delete", serde_json::json!({ "provider": "groq", }), ) .await .expect("dictation secret delete should succeed"); assert!( matches!( Config::global().get_secret::("GROQ_API_KEY"), Err(ConfigError::NotFound(_)) ), "ACP dictation secret delete should invalidate the global secrets cache" ); let save_provider_config = send_custom( conn.cx(), "_goose/unstable/providers/config/save", serde_json::json!({ "providerId": "xai", "fields": [ { "key": "XAI_API_KEY", "value": "xai-provider-config-key", }, { "key": "XAI_HOST", "value": "https://api.x.ai/v1", }, ], }), ) .await .expect("provider config save should succeed"); assert_eq!( save_provider_config.get("status"), Some(&serde_json::json!({ "providerId": "xai", "isConfigured": true, })), "provider config save should return the updated configured status" ); assert_eq!( save_provider_config.get("refresh"), Some(&serde_json::json!({ "started": [], "skipped": [ { "providerId": "xai", "reason": "does_not_support_refresh", }, ], })), "provider config save should return the inventory refresh acknowledgement" ); assert_eq!( Config::global() .get_secret::("XAI_API_KEY") .unwrap(), "xai-provider-config-key", "provider config save should invalidate the global secrets cache" ); let read_provider_config = send_custom( conn.cx(), "_goose/unstable/providers/config/read", serde_json::json!({ "providerId": "xai", }), ) .await .expect("provider config read should succeed"); let fields = read_provider_config .get("fields") .and_then(|fields| fields.as_array()) .expect("provider config read should return fields"); let api_key_field = fields .iter() .find(|field| field.get("key") == Some(&serde_json::json!("XAI_API_KEY"))) .expect("provider config read should include the API key"); assert_eq!(api_key_field.get("isSet"), Some(&serde_json::json!(true))); assert_ne!( api_key_field.get("value"), Some(&serde_json::json!("xai-provider-config-key")), "provider config read should mask secret values" ); let delete_provider_config = send_custom( conn.cx(), "_goose/unstable/providers/config/delete", serde_json::json!({ "providerId": "xai", }), ) .await .expect("provider config delete should succeed"); assert_eq!( delete_provider_config.get("status"), Some(&serde_json::json!({ "providerId": "xai", "isConfigured": false, })), "provider config delete should return the updated configured status" ); assert!( matches!( Config::global().get_secret::("XAI_API_KEY"), Err(ConfigError::NotFound(_)) ), "provider config delete should invalidate the global secrets cache" ); Config::global().invalidate_secrets_cache(); assert!(Config::global() .get_secret::("ANTHROPIC_API_KEY") .is_err()); write_secrets(&config_dir, "ANTHROPIC_API_KEY: anthropic-key\n"); let refresh = send_custom( conn.cx(), "_goose/unstable/providers/inventory/refresh", serde_json::json!({ "providerIds": ["anthropic"], }), ) .await .expect("inventory refresh should succeed"); assert_eq!( refresh.get("started"), Some(&serde_json::json!(["anthropic"])), "inventory refresh should invalidate the global secrets cache before planning" ); write_secrets(&config_dir, "OPENAI_API_KEY: plan-time-key\n"); Config::global().invalidate_secrets_cache(); let inventory = ProviderInventoryService::new(Arc::new(SessionStorage::new(data_dir))); let plan = inventory .plan_refresh(&["openai".to_string()]) .await .expect("plan refresh should start for configured OpenAI provider"); assert_eq!(plan.started, vec!["openai".to_string()]); let entry_during_refresh = inventory .entry_for_provider("openai") .await .expect("entry should load while refresh is in progress") .expect("OpenAI inventory entry should exist"); assert!( entry_during_refresh.refreshing, "plan refresh should mark the plan-time identity as refreshing" ); let sentinel_model = "stark-plan-time-model".to_string(); inventory .store_refreshed_models("openai", std::slice::from_ref(&sentinel_model)) .await .expect("public store_refreshed_models compatibility wrapper should succeed"); let plan_time_entry = inventory .entry_for_provider("openai") .await .expect("entry should load for plan-time credentials") .expect("OpenAI inventory entry should exist for plan-time credentials"); assert!( !plan_time_entry.refreshing, "store with captured identity should clear the plan-time refreshing key" ); assert!( plan_time_entry .models .iter() .any(|model| model.id == sentinel_model), "models should be stored under the identity captured at plan time" ); }); }