#[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::base::CONFIG_YAML_NAME; use goose::config::declarative_providers::load_provider; use goose::config::paths::Paths; use goose::config::{Config, ConfigError, DeclarativeProviderConfig}; use goose_test_support::EnforceSessionId; use serial_test::serial; use std::sync::Arc; fn write_config(config_dir: &std::path::Path, contents: &str) { std::fs::create_dir_all(config_dir).unwrap(); std::fs::write(config_dir.join(CONFIG_YAML_NAME), contents).unwrap(); } fn write_secrets(config_dir: &std::path::Path, contents: &str) { std::fs::write(config_dir.join("secrets.yaml"), contents).unwrap(); } #[test] #[serial] fn acp_catalog_and_custom_provider_methods_use_core_provider_store() { 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")), ("XAI_API_KEY", None), ("XAI_HOST", None), ("CUSTOM_STARK_ACP_PROVIDER_API_KEY", None), ]); let config_dir = Paths::config_dir(); write_config( &config_dir, "GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_DISABLE_KEYRING: true\nXAI_HOST: https://api.x.ai/v1\n", ); write_secrets(&config_dir, "XAI_API_KEY: xai-configured-key\n"); Config::global().invalidate_secrets_cache(); run_test(async move { let openai = common_tests::fixtures::OpenAiFixture::new( vec![], Arc::new(EnforceSessionId::default()), ) .await; let config = TestConnectionConfig { data_root: config_dir.clone(), ..Default::default() }; let conn = AcpServerConnection::new(config, openai).await; let catalog = send_custom( conn.cx(), "_goose/unstable/providers/catalog/list", serde_json::json!({ "format": "openai" }), ) .await .expect("provider catalog list should succeed"); let catalog_providers = catalog .get("providers") .and_then(|providers| providers.as_array()) .expect("catalog response should include providers"); assert!( catalog_providers .iter() .any(|provider| provider.get("providerId") == Some(&serde_json::json!("opencode"))), "OpenAI-compatible catalog should include OpenCode Zen" ); let setup_catalog = send_custom( conn.cx(), "_goose/unstable/providers/setup/catalog/list", serde_json::json!({}), ) .await .expect("provider setup catalog list should succeed"); let setup_providers = setup_catalog .get("providers") .and_then(|providers| providers.as_array()) .expect("setup catalog response should include providers"); for provider_id in [ "goose", "anthropic", "openai", "claude-acp", "codex-acp", "copilot-acp", "amp-acp", "cursor-agent", "pi-acp", ] { assert!( setup_providers .iter() .any(|provider| provider.get("providerId") == Some(&serde_json::json!(provider_id))), "setup catalog should include {provider_id}" ); } for provider_id in ["codex", "claude_code", "gemini_cli"] { assert!( setup_providers .iter() .all(|provider| provider.get("providerId") != Some(&serde_json::json!(provider_id))), "setup catalog should exclude deprecated provider {provider_id}" ); } let codex_setup = setup_providers .iter() .find(|provider| provider.get("providerId") == Some(&serde_json::json!("codex-acp"))) .expect("setup catalog should include codex-acp"); assert_eq!( codex_setup.get("category"), Some(&serde_json::json!("agent")) ); assert_eq!( codex_setup.get("setupMethod"), Some(&serde_json::json!("cli_auth")) ); assert_eq!( codex_setup.get("supportsInstall"), Some(&serde_json::json!(true)) ); assert!( codex_setup .get("aliases") .and_then(|aliases| aliases.as_array()) .is_some_and(|aliases| aliases.contains(&serde_json::json!("codex"))), "codex-acp setup aliases should include codex" ); let template = send_custom( conn.cx(), "_goose/unstable/providers/catalog/template", serde_json::json!({ "providerId": "zai" }), ) .await .expect("provider catalog template should succeed"); assert_eq!( template.pointer("/template/providerId"), Some(&serde_json::json!("zai")) ); assert!( template .pointer("/template/models") .and_then(|models| models.as_array()) .is_some_and(|models| !models.is_empty()), "provider template should expose model templates" ); let configured_status = send_custom( conn.cx(), "_goose/unstable/providers/config/status", serde_json::json!({ "providerIds": ["xai"] }), ) .await .expect("provider config status should succeed"); assert_eq!( configured_status.pointer("/statuses/0"), Some(&serde_json::json!({ "providerId": "xai", "isConfigured": true, })), "provider configured through core config should be configured through ACP" ); let configured_read = send_custom( conn.cx(), "_goose/unstable/providers/config/read", serde_json::json!({ "providerId": "xai" }), ) .await .expect("provider config read should succeed"); let fields = configured_read .get("fields") .and_then(|fields| fields.as_array()) .expect("provider config read should include fields"); let xai_key = fields .iter() .find(|field| field.get("key") == Some(&serde_json::json!("XAI_API_KEY"))) .expect("provider config read should include XAI_API_KEY"); assert_eq!(xai_key.get("isSet"), Some(&serde_json::json!(true))); assert_ne!( xai_key.get("value"), Some(&serde_json::json!("xai-configured-key")), "provider config read should not expose raw secret values" ); let non_oauth_auth = send_custom( conn.cx(), "_goose/unstable/providers/config/authenticate", serde_json::json!({ "providerId": "xai" }), ) .await; assert!( non_oauth_auth.is_err(), "native auth should reject providers without an OAuth flow" ); Config::global().invalidate_secrets_cache(); assert!(Config::global() .get_secret::("CUSTOM_STARK_ACP_PROVIDER_API_KEY") .is_err()); let created = send_custom( conn.cx(), "_goose/unstable/providers/custom/create", serde_json::json!({ "engine": "openai_compatible", "displayName": "Stark ACP Provider", "apiUrl": "https://stark.example/v1", "apiKey": "created-custom-key", "models": ["stark-1", "stark-2"], "supportsStreaming": true, "headers": { "X-Stark": "enabled" }, "requiresAuth": true, "catalogProviderId": "openai", "basePath": "v1/chat/completions" }), ) .await .expect("custom provider create should succeed"); let provider_id = created .get("providerId") .and_then(|provider_id| provider_id.as_str()) .expect("custom provider create should return providerId") .to_string(); assert_eq!(provider_id, "custom_stark_acp_provider"); assert_eq!( created.get("status"), Some(&serde_json::json!({ "providerId": provider_id, "isConfigured": true, })), "create should invalidate the secret cache before status checks" ); assert_eq!( created.get("refresh"), Some(&serde_json::json!({ "started": [], "skipped": [ { "providerId": provider_id, "reason": "does_not_support_refresh", }, ], })) ); let custom_provider_path = Paths::config_dir() .join("custom_providers") .join(format!("{provider_id}.json")); assert!( custom_provider_path.exists(), "custom provider should be saved in Goose's declarative provider store" ); let saved_provider: DeclarativeProviderConfig = serde_json::from_str(&std::fs::read_to_string(&custom_provider_path).unwrap()) .expect("saved provider should be core-compatible declarative config"); assert_eq!(saved_provider.name, provider_id); assert_eq!(saved_provider.display_name, "Stark ACP Provider"); assert_eq!(saved_provider.base_url, "https://stark.example/v1"); assert!(saved_provider.preserves_thinking); assert_eq!( saved_provider .models .iter() .map(|model| model.name.as_str()) .collect::>(), vec!["stark-1", "stark-2"] ); assert_eq!( Config::global() .get_secret::("CUSTOM_STARK_ACP_PROVIDER_API_KEY") .unwrap(), "created-custom-key", "custom provider create should write through Goose's config store" ); assert!( load_provider(&provider_id) .expect("core should load the ACP-created custom provider") .is_editable ); let read = send_custom( conn.cx(), "_goose/unstable/providers/custom/read", serde_json::json!({ "providerId": provider_id }), ) .await .expect("custom provider read should succeed"); assert_eq!(read.get("editable"), Some(&serde_json::json!(true))); assert_eq!( read.pointer("/provider"), Some(&serde_json::json!({ "providerId": provider_id, "engine": "openai_compatible", "displayName": "Stark ACP Provider", "apiUrl": "https://stark.example/v1", "models": ["stark-1", "stark-2"], "supportsStreaming": true, "headers": { "X-Stark": "enabled" }, "requiresAuth": true, "catalogProviderId": "openai", "basePath": "v1/chat/completions", "apiKeyEnv": "CUSTOM_STARK_ACP_PROVIDER_API_KEY", "apiKeySet": true, "preservesThinking": true, })) ); let inventory = send_custom( conn.cx(), "_goose/unstable/providers/list", serde_json::json!({ "providerIds": [provider_id] }), ) .await .expect("provider inventory list should include custom provider"); assert_eq!( inventory.pointer("/entries/0/providerType"), Some(&serde_json::json!("Custom")) ); assert_eq!( inventory.pointer("/entries/0/providerId"), Some(&serde_json::json!(provider_id)) ); let updated = send_custom( conn.cx(), "_goose/unstable/providers/custom/update", serde_json::json!({ "providerId": provider_id, "engine": "openai", "displayName": "Stark ACP Provider Updated", "apiUrl": "https://stark.example/openai", "apiKey": "updated-custom-key", "models": ["stark-3"], "supportsStreaming": false, "headers": {}, "requiresAuth": true, "catalogProviderId": "zai", "preservesThinking": false }), ) .await .expect("custom provider update should succeed"); assert_eq!( updated.get("status"), Some(&serde_json::json!({ "providerId": provider_id, "isConfigured": true, })), "update should invalidate the secret cache before status checks" ); assert_eq!( Config::global() .get_secret::("CUSTOM_STARK_ACP_PROVIDER_API_KEY") .unwrap(), "updated-custom-key", "custom provider update should write through Goose's config store" ); let updated_provider: DeclarativeProviderConfig = serde_json::from_str(&std::fs::read_to_string(&custom_provider_path).unwrap()) .expect("updated provider should remain core-compatible"); assert_eq!(updated_provider.display_name, "Stark ACP Provider Updated"); assert_eq!(updated_provider.base_url, "https://stark.example/openai"); assert_eq!( updated_provider.catalog_provider_id, Some("zai".to_string()) ); assert_eq!(updated_provider.base_path, None); assert_eq!(updated_provider.headers, None); assert!(!updated_provider.preserves_thinking); assert_eq!( updated_provider .models .iter() .map(|model| model.name.as_str()) .collect::>(), vec!["stark-3"] ); let auth_disabled = send_custom( conn.cx(), "_goose/unstable/providers/custom/update", serde_json::json!({ "providerId": provider_id, "engine": "openai_compatible", "displayName": "Stark ACP Provider No Auth", "apiUrl": "https://stark.example/openai", "apiKey": "", "models": ["stark-3"], "supportsStreaming": false, "headers": {}, "requiresAuth": false, "catalogProviderId": "zai" }), ) .await .expect("custom provider auth disable should succeed"); assert_eq!( auth_disabled.get("status"), Some(&serde_json::json!({ "providerId": provider_id, "isConfigured": true, })), "auth disable should invalidate the secret cache before status checks" ); let no_auth_provider: DeclarativeProviderConfig = serde_json::from_str(&std::fs::read_to_string(&custom_provider_path).unwrap()) .expect("no-auth provider should remain core-compatible"); assert!(!no_auth_provider.requires_auth); assert_eq!(no_auth_provider.api_key_env, ""); assert!(!no_auth_provider.preserves_thinking); assert!( matches!( Config::global().get_secret::("CUSTOM_STARK_ACP_PROVIDER_API_KEY"), Err(ConfigError::NotFound(_)) ), "disabling auth should delete the previously stored API key" ); let auth_reenabled_without_key = send_custom( conn.cx(), "_goose/unstable/providers/custom/update", serde_json::json!({ "providerId": provider_id, "engine": "openai_compatible", "displayName": "Stark ACP Provider Reauth", "apiUrl": "https://stark.example/openai", "apiKey": "", "models": ["stark-3"], "supportsStreaming": false, "headers": {}, "requiresAuth": true, "catalogProviderId": "zai" }), ) .await .expect_err("re-enabling auth without a stored secret should fail"); assert!( auth_reenabled_without_key .to_string() .contains("apiKey is required"), "unexpected error: {auth_reenabled_without_key}" ); assert!( matches!( Config::global().get_secret::("CUSTOM_STARK_ACP_PROVIDER_API_KEY"), Err(ConfigError::NotFound(_)) ), "blank re-enable should not recreate the previous API key" ); let deleted = send_custom( conn.cx(), "_goose/unstable/providers/custom/delete", serde_json::json!({ "providerId": provider_id }), ) .await .expect("custom provider delete should succeed"); assert_eq!( deleted.pointer("/providerId"), Some(&serde_json::json!(provider_id)) ); assert_eq!( deleted.get("refresh"), Some(&serde_json::json!({ "started": [], "skipped": [], })) ); assert!( !custom_provider_path.exists(), "custom provider delete should remove the declarative provider file" ); assert!( matches!( Config::global().get_secret::("CUSTOM_STARK_ACP_PROVIDER_API_KEY"), Err(ConfigError::NotFound(_)) ), "custom provider delete should invalidate the secret cache before later reads" ); let deleted_status = send_custom( conn.cx(), "_goose/unstable/providers/config/status", serde_json::json!({ "providerIds": [provider_id] }), ) .await .expect("provider config status should succeed after delete"); assert_eq!( deleted_status.pointer("/statuses/0"), Some(&serde_json::json!({ "providerId": provider_id, "isConfigured": false, })) ); for invalid_id in [ "../escape", "foo/bar", ".hidden", "-bad", "", "Uppercase", "has space", ] { let read = send_custom( conn.cx(), "_goose/unstable/providers/custom/read", serde_json::json!({ "providerId": invalid_id }), ) .await; assert!( read.is_err(), "invalid provider id should fail: {invalid_id:?}" ); } for valid_id in ["custom_openai", "openai-compat", "a1"] { assert!( goose::config::declarative_providers::validate_provider_id(valid_id).is_ok(), "provider id should be valid: {valid_id}" ); } for (name, patch) in [ ( "ftp URL", serde_json::json!({ "apiUrl": "ftp://example.com" }), ), ("relative URL", serde_json::json!({ "apiUrl": "/v1" })), ("empty models", serde_json::json!({ "models": [] })), ("blank models", serde_json::json!({ "models": [" ", "\n"] })), ( "invalid header name", serde_json::json!({ "headers": { "Bad Header": "value" } }), ), ( "invalid header value", serde_json::json!({ "headers": { "X-Test": "bad\r\nvalue" } }), ), ( "unsupported engine", serde_json::json!({ "engine": "future_engine" }), ), ] { let mut payload = serde_json::json!({ "engine": "openai_compatible", "displayName": format!("Invalid {name}"), "apiUrl": "https://api.example.test/v1", "apiKey": "secret", "models": ["model-a"], "headers": {}, "requiresAuth": true }); let payload_obj = payload.as_object_mut().unwrap(); for (key, value) in patch.as_object().unwrap() { payload_obj.insert(key.clone(), value.clone()); } let result = send_custom( conn.cx(), "_goose/unstable/providers/custom/create", payload, ) .await; assert!(result.is_err(), "{name} should be rejected"); } Config::global() .set_secret("SHARED_API_KEY", &"shared-secret") .unwrap(); let shared = send_custom( conn.cx(), "_goose/unstable/providers/custom/create", serde_json::json!({ "engine": "openai_compatible", "displayName": "Shared Secret Test", "apiUrl": "https://api.example.test/v1", "apiKey": "owned-secret", "models": ["model-a"], "headers": {}, "requiresAuth": true }), ) .await .expect("shared-secret provider create should succeed"); let shared_id = shared .get("providerId") .and_then(|provider_id| provider_id.as_str()) .unwrap() .to_string(); let shared_path = Paths::config_dir() .join("custom_providers") .join(format!("{shared_id}.json")); let mut shared_config: DeclarativeProviderConfig = serde_json::from_str(&std::fs::read_to_string(&shared_path).unwrap()).unwrap(); shared_config.api_key_env = "SHARED_API_KEY".to_string(); std::fs::write( &shared_path, serde_json::to_string_pretty(&shared_config).unwrap(), ) .unwrap(); Config::global().invalidate_secrets_cache(); send_custom( conn.cx(), "_goose/unstable/providers/custom/update", serde_json::json!({ "providerId": shared_id, "engine": "openai_compatible", "displayName": "Shared Secret Test", "apiUrl": "https://api.example.test/v1", "models": ["model-a"], "headers": {}, "requiresAuth": false }), ) .await .expect("disabling auth should preserve shared secrets"); assert_eq!( Config::global() .get_secret::("SHARED_API_KEY") .unwrap(), "shared-secret" ); let shared_delete = send_custom( conn.cx(), "_goose/unstable/providers/custom/create", serde_json::json!({ "engine": "openai_compatible", "displayName": "Shared Secret Delete", "apiUrl": "https://api.example.test/v1", "apiKey": "owned-secret", "models": ["model-a"], "headers": {}, "requiresAuth": true }), ) .await .expect("shared-delete provider create should succeed"); let shared_delete_id = shared_delete .get("providerId") .and_then(|provider_id| provider_id.as_str()) .unwrap() .to_string(); let shared_delete_path = Paths::config_dir() .join("custom_providers") .join(format!("{shared_delete_id}.json")); let mut shared_delete_config: DeclarativeProviderConfig = serde_json::from_str(&std::fs::read_to_string(&shared_delete_path).unwrap()).unwrap(); shared_delete_config.api_key_env = "SHARED_API_KEY".to_string(); std::fs::write( &shared_delete_path, serde_json::to_string_pretty(&shared_delete_config).unwrap(), ) .unwrap(); Config::global().invalidate_secrets_cache(); send_custom( conn.cx(), "_goose/unstable/providers/custom/delete", serde_json::json!({ "providerId": shared_delete_id }), ) .await .expect("deleting provider should preserve shared secrets"); assert_eq!( Config::global() .get_secret::("SHARED_API_KEY") .unwrap(), "shared-secret" ); }); }