Files
tkmind_go/crates/goose/tests/acp_custom_provider_methods_test.rs
2026-05-22 21:04:46 +00:00

689 lines
25 KiB
Rust

#[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::<String>("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<_>>(),
vec!["stark-1", "stark-2"]
);
assert_eq!(
Config::global()
.get_secret::<String>("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::<String>("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<_>>(),
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::<String>("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::<String>("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::<String>("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::<String>("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::<String>("SHARED_API_KEY")
.unwrap(),
"shared-secret"
);
});
}