refactor: update goose2 credential management behind provider-scoped ACP/core API (#8887)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
Kalvin C
2026-04-29 09:05:32 -07:00
committed by GitHub
parent 503ad20494
commit 899762a422
39 changed files with 3079 additions and 1118 deletions
@@ -1,50 +0,0 @@
use tauri::State;
use crate::services::goose_config::{FieldValue, GooseConfig, ProviderStatus};
use crate::services::provider_defs::find_config_key;
#[tauri::command]
pub fn get_provider_config(
config: State<'_, GooseConfig>,
provider_id: String,
) -> Result<Vec<FieldValue>, String> {
config.get_provider_field_values(&provider_id)
}
#[tauri::command]
pub fn save_provider_field(
config: State<'_, GooseConfig>,
key: String,
value: String,
) -> Result<(), String> {
let config_key =
find_config_key(&key).ok_or_else(|| format!("Unknown provider config key '{key}'"))?;
let trimmed_value = value.trim();
if trimmed_value.is_empty() {
return Err("Field value cannot be empty".to_string());
}
if config_key.is_secret {
config.set_secret(&key, trimmed_value)
} else {
config.set_param(&key, trimmed_value)
}
}
#[tauri::command]
pub fn delete_provider_config(
config: State<'_, GooseConfig>,
provider_id: String,
) -> Result<(), String> {
config.delete_all_provider_fields(&provider_id)
}
#[tauri::command]
pub fn check_all_provider_status(config: State<'_, GooseConfig>) -> Vec<ProviderStatus> {
config.check_all_provider_status()
}
#[tauri::command]
pub fn restart_app(app: tauri::AppHandle) {
app.restart();
}
-1
View File
@@ -1,7 +1,6 @@
pub mod acp;
pub mod agent_setup;
pub mod agents;
pub mod credentials;
pub mod doctor;
pub mod git;
pub mod git_changes;
+1 -8
View File
@@ -2,7 +2,6 @@ mod commands;
mod services;
mod types;
use services::goose_config::GooseConfig;
use services::personas::PersonaStore;
use tauri_plugin_window_state::StateFlags;
@@ -25,8 +24,7 @@ pub fn run() {
.with_state_flags(StateFlags::all() & !StateFlags::VISIBLE)
.build(),
)
.manage(PersonaStore::new())
.manage(GooseConfig::new());
.manage(PersonaStore::new());
#[cfg(feature = "app-test-driver")]
let builder = builder.plugin(tauri_plugin_app_test_driver::init());
@@ -65,11 +63,6 @@ pub fn run() {
commands::git::git_pull,
commands::git::git_create_branch,
commands::git::git_create_worktree,
commands::credentials::get_provider_config,
commands::credentials::save_provider_field,
commands::credentials::delete_provider_config,
commands::credentials::check_all_provider_status,
commands::credentials::restart_app,
commands::model_setup::authenticate_model_provider,
commands::agent_setup::check_agent_installed,
commands::agent_setup::check_agent_auth,
@@ -1,452 +0,0 @@
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Mutex;
use etcetera::{choose_app_strategy, AppStrategy, AppStrategyArgs};
use serde::Serialize;
use serde_json::Value;
use super::provider_defs::{find_provider_def, PROVIDER_CONFIG_DEFS};
const KEYRING_SERVICE: &str = "goose";
const KEYRING_USERNAME: &str = "secrets";
const CONFIG_YAML_NAME: &str = "config.yaml";
const SECRETS_YAML_NAME: &str = "secrets.yaml";
const SECRET_MASK_PREFIX_LEN: usize = 4;
const SECRET_MASK_SUFFIX_LEN: usize = 3;
const SECRET_MASK_FALLBACK: &str = "***";
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ProviderStatus {
pub provider_id: String,
pub is_configured: bool,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct FieldValue {
pub key: String,
pub value: Option<String>,
pub is_set: bool,
pub is_secret: bool,
pub required: bool,
}
pub struct GooseConfig {
config_dir: PathBuf,
guard: Mutex<()>,
}
impl GooseConfig {
pub fn new() -> Self {
let config_dir = Self::resolve_config_dir();
log::info!("GooseConfig using config dir: {}", config_dir.display());
Self {
config_dir,
guard: Mutex::new(()),
}
}
fn resolve_config_dir() -> PathBuf {
if let Ok(root) = std::env::var("GOOSE_PATH_ROOT") {
return PathBuf::from(root).join("config");
}
let strategy = choose_app_strategy(AppStrategyArgs {
top_level_domain: "Block".to_string(),
author: "Block".to_string(),
app_name: "goose".to_string(),
})
.expect("goose requires a home dir");
strategy.config_dir()
}
fn read_config_map(&self) -> serde_yaml::Mapping {
let config_path = self.config_dir.join(CONFIG_YAML_NAME);
match std::fs::read_to_string(&config_path) {
Ok(contents) => {
serde_yaml::from_str::<serde_yaml::Mapping>(&contents).unwrap_or_default()
}
Err(_) => serde_yaml::Mapping::new(),
}
}
fn write_config_map(&self, config: &serde_yaml::Mapping) -> Result<(), String> {
let path = self.config_dir.join(CONFIG_YAML_NAME);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let yaml = serde_yaml::to_string(config)
.map_err(|e| format!("Failed to serialize config: {e}"))?;
std::fs::write(&path, yaml).map_err(|e| format!("Failed to write config file: {e}"))
}
fn get_secret(&self, key: &str) -> Option<String> {
let env_key = key.to_uppercase();
if let Ok(value) = std::env::var(&env_key) {
return Some(value);
}
let secrets = self.all_secrets();
secrets
.get(key)
.and_then(|value| value.as_str().map(|secret| secret.to_string()))
}
fn mask_secret_value(value: &str) -> String {
let prefix: String = value.chars().take(SECRET_MASK_PREFIX_LEN).collect();
let suffix_chars: Vec<char> = value.chars().rev().take(SECRET_MASK_SUFFIX_LEN).collect();
let suffix: String = suffix_chars.into_iter().rev().collect();
if prefix.is_empty()
|| suffix.is_empty()
|| value.chars().count() <= SECRET_MASK_PREFIX_LEN + SECRET_MASK_SUFFIX_LEN
{
return SECRET_MASK_FALLBACK.to_string();
}
format!("{prefix}...{suffix}")
}
fn has_param(&self, key: &str) -> bool {
self.get_param(key).is_some()
}
fn has_secret(&self, key: &str) -> bool {
self.get_secret(key).is_some()
}
pub fn get_param(&self, key: &str) -> Option<String> {
let env_key = key.to_uppercase();
if let Ok(value) = std::env::var(&env_key) {
return Some(value);
}
let config = self.read_config_map();
let yaml_key = serde_yaml::Value::String(key.to_string());
config
.get(&yaml_key)
.and_then(|value| value.as_str().map(|param| param.to_string()))
}
pub fn get_secret_masked(&self, key: &str) -> Option<String> {
self.get_secret(key)
.map(|value| Self::mask_secret_value(&value))
}
pub fn set_param(&self, key: &str, value: &str) -> Result<(), String> {
let _guard = self.guard.lock().unwrap();
let mut config = self.read_config_map();
config.insert(
serde_yaml::Value::String(key.to_string()),
serde_yaml::Value::String(value.to_string()),
);
self.write_config_map(&config)
}
pub fn delete_param(&self, key: &str) -> Result<bool, String> {
let _guard = self.guard.lock().unwrap();
let mut config = self.read_config_map();
let yaml_key = serde_yaml::Value::String(key.to_string());
let removed = config.remove(&yaml_key).is_some();
if removed {
self.write_config_map(&config)?;
}
Ok(removed)
}
pub fn set_secret(&self, key: &str, value: &str) -> Result<(), String> {
let _guard = self.guard.lock().unwrap();
let mut secrets = self.all_secrets();
secrets.insert(key.to_string(), Value::String(value.to_string()));
if self.is_keyring_disabled() {
self.write_secrets_to_file(&secrets)
} else {
self.write_secrets_to_keyring(&secrets)
}
}
pub fn delete_secret(&self, key: &str) -> Result<bool, String> {
let _guard = self.guard.lock().unwrap();
let mut secrets = self.all_secrets();
let removed = secrets.remove(key).is_some();
if removed {
if self.is_keyring_disabled() {
self.write_secrets_to_file(&secrets)?;
} else {
self.write_secrets_to_keyring(&secrets)?;
}
}
Ok(removed)
}
fn has_oauth_cache(&self, cache_path: &str) -> bool {
let full_path = self.config_dir.join(cache_path);
if full_path.is_dir() {
std::fs::read_dir(&full_path)
.map(|mut entries| entries.any(|_| true))
.unwrap_or(false)
} else {
full_path.exists()
}
}
fn delete_oauth_cache(&self, cache_path: &str) -> Result<bool, String> {
let _guard = self.guard.lock().unwrap();
let full_path = self.config_dir.join(cache_path);
if full_path.is_dir() {
std::fs::remove_dir_all(&full_path)
.map_err(|e| format!("Failed to remove OAuth cache directory: {e}"))?;
return Ok(true);
}
if full_path.exists() {
std::fs::remove_file(&full_path)
.map_err(|e| format!("Failed to remove OAuth cache file: {e}"))?;
return Ok(true);
}
Ok(false)
}
pub fn check_provider_status(&self, provider_id: &str) -> ProviderStatus {
if provider_id == "databricks" {
let has_host = self.has_param("DATABRICKS_HOST");
let has_token = self.has_secret("DATABRICKS_TOKEN");
let has_oauth = self.has_oauth_cache("databricks/oauth");
return ProviderStatus {
provider_id: provider_id.to_string(),
is_configured: has_host && (has_token || has_oauth),
};
}
let def = match find_provider_def(provider_id) {
Some(def) => def,
None => {
return ProviderStatus {
provider_id: provider_id.to_string(),
is_configured: false,
}
}
};
let has_oauth = def
.oauth_cache_path
.map(|p| self.has_oauth_cache(p))
.unwrap_or(false);
if has_oauth {
return ProviderStatus {
provider_id: provider_id.to_string(),
is_configured: true,
};
}
let all_required_present = def.keys.iter().all(|k| {
if !k.required {
return true;
}
if k.is_secret {
self.has_secret(k.name)
} else {
self.has_param(k.name)
}
});
let has_any_key = def.keys.iter().any(|k| {
if k.is_secret {
self.has_secret(k.name)
} else {
self.has_param(k.name)
}
});
let is_configured = if def.keys.is_empty() {
false
} else {
all_required_present && has_any_key
};
ProviderStatus {
provider_id: provider_id.to_string(),
is_configured,
}
}
pub fn check_all_provider_status(&self) -> Vec<ProviderStatus> {
PROVIDER_CONFIG_DEFS
.iter()
.map(|def| self.check_provider_status(def.id))
.collect()
}
pub fn get_provider_field_values(&self, provider_id: &str) -> Result<Vec<FieldValue>, String> {
let def = find_provider_def(provider_id)
.ok_or_else(|| format!("Unknown provider '{provider_id}'"))?;
Ok(def
.keys
.iter()
.map(|config_key| {
let value = if config_key.is_secret {
self.get_secret_masked(config_key.name)
} else {
self.get_param(config_key.name)
};
FieldValue {
key: config_key.name.to_string(),
is_set: value.is_some(),
value,
is_secret: config_key.is_secret,
required: config_key.required,
}
})
.collect())
}
pub fn delete_all_provider_fields(&self, provider_id: &str) -> Result<(), String> {
let def = find_provider_def(provider_id)
.ok_or_else(|| format!("Unknown provider '{provider_id}'"))?;
for config_key in def.keys {
if config_key.is_secret {
self.delete_secret(config_key.name)?;
} else {
self.delete_param(config_key.name)?;
}
}
if let Some(oauth_cache_path) = def.oauth_cache_path {
self.delete_oauth_cache(oauth_cache_path)?;
}
Ok(())
}
fn is_keyring_disabled(&self) -> bool {
if std::env::var("GOOSE_DISABLE_KEYRING").is_ok() {
return true;
}
let config_path = self.config_dir.join(CONFIG_YAML_NAME);
if let Ok(contents) = std::fs::read_to_string(&config_path) {
if let Ok(yaml) = serde_yaml::from_str::<serde_yaml::Mapping>(&contents) {
let key = serde_yaml::Value::String("GOOSE_DISABLE_KEYRING".to_string());
if let Some(val) = yaml.get(&key) {
return val.as_bool().unwrap_or(false)
|| val
.as_str()
.map(|s| s == "true" || s == "1")
.unwrap_or(false);
}
}
}
false
}
fn all_secrets(&self) -> HashMap<String, Value> {
if self.is_keyring_disabled() {
return self.read_secrets_from_file();
}
match self.read_secrets_from_keyring() {
Ok(secrets) => secrets,
Err(e) => {
log::warn!("Keyring read failed, falling back to secrets file: {e}");
self.read_secrets_from_file()
}
}
}
fn read_secrets_from_keyring(&self) -> Result<HashMap<String, Value>, String> {
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USERNAME)
.map_err(|e| format!("Failed to access keyring: {e}"))?;
match entry.get_password() {
Ok(json_str) => serde_json::from_str(&json_str)
.map_err(|e| format!("Failed to parse keyring JSON: {e}")),
Err(keyring::Error::NoEntry) => Ok(HashMap::new()),
Err(e) => Err(format!("Failed to read keyring: {e}")),
}
}
fn read_secrets_from_file(&self) -> HashMap<String, Value> {
let path = self.config_dir.join(SECRETS_YAML_NAME);
match std::fs::read_to_string(&path) {
Ok(contents) => serde_yaml::from_str::<HashMap<String, String>>(&contents)
.unwrap_or_default()
.into_iter()
.map(|(k, v)| (k, Value::String(v)))
.collect(),
Err(_) => HashMap::new(),
}
}
fn write_secrets_to_keyring(&self, secrets: &HashMap<String, Value>) -> Result<(), String> {
let entry = keyring::Entry::new(KEYRING_SERVICE, KEYRING_USERNAME)
.map_err(|e| format!("Failed to access keyring: {e}"))?;
let json_str = serde_json::to_string(secrets)
.map_err(|e| format!("Failed to serialize secrets: {e}"))?;
entry
.set_password(&json_str)
.map_err(|e| format!("Failed to write to keyring: {e}"))
}
fn write_secrets_to_file(&self, secrets: &HashMap<String, Value>) -> Result<(), String> {
let path = self.config_dir.join(SECRETS_YAML_NAME);
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let yaml_map: HashMap<String, String> = secrets
.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect();
let yaml = serde_yaml::to_string(&yaml_map)
.map_err(|e| format!("Failed to serialize secrets: {e}"))?;
#[cfg(unix)]
{
use std::fs::OpenOptions;
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&path)
.map_err(|e| format!("Failed to open secrets file: {e}"))?;
file.write_all(yaml.as_bytes())
.map_err(|e| format!("Failed to write secrets file: {e}"))
}
#[cfg(not(unix))]
{
std::fs::write(&path, yaml).map_err(|e| format!("Failed to write secrets file: {e}"))
}
}
}
-2
View File
@@ -1,4 +1,2 @@
pub mod acp;
pub mod goose_config;
pub mod personas;
pub(crate) mod provider_defs;
@@ -1,150 +0,0 @@
pub(crate) struct ConfigKey {
pub name: &'static str,
pub is_secret: bool,
pub required: bool,
}
pub(crate) struct ProviderConfigDef {
pub id: &'static str,
pub keys: &'static [ConfigKey],
pub oauth_cache_path: Option<&'static str>,
}
const fn key(name: &'static str, is_secret: bool, required: bool) -> ConfigKey {
ConfigKey {
name,
is_secret,
required,
}
}
pub(crate) static PROVIDER_CONFIG_DEFS: &[ProviderConfigDef] = &[
ProviderConfigDef {
id: "anthropic",
keys: &[key("ANTHROPIC_API_KEY", true, true)],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "openai",
keys: &[key("OPENAI_API_KEY", true, true)],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "google",
keys: &[key("GOOGLE_API_KEY", true, true)],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "openrouter",
keys: &[key("OPENROUTER_API_KEY", true, true)],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "xai",
keys: &[key("XAI_API_KEY", true, true)],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "nanogpt",
keys: &[key("NANOGPT_API_KEY", true, true)],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "venice",
keys: &[key("VENICE_API_KEY", true, true)],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "tetrate",
keys: &[key("TETRATE_API_KEY", true, true)],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "databricks",
keys: &[
key("DATABRICKS_HOST", false, true),
key("DATABRICKS_TOKEN", true, false),
],
oauth_cache_path: Some("databricks/oauth"),
},
ProviderConfigDef {
id: "snowflake",
keys: &[
key("SNOWFLAKE_HOST", false, true),
key("SNOWFLAKE_TOKEN", true, true),
],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "litellm",
keys: &[
key("LITELLM_HOST", false, true),
key("LITELLM_API_KEY", true, false),
],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "azure",
keys: &[
key("AZURE_OPENAI_ENDPOINT", false, true),
key("AZURE_OPENAI_DEPLOYMENT_NAME", false, true),
key("AZURE_OPENAI_API_KEY", true, false),
],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "bedrock",
keys: &[key("AWS_REGION", false, false)],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "gcp_vertex_ai",
keys: &[
key("GCP_PROJECT_ID", false, true),
key("GCP_LOCATION", false, true),
],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "chatgpt_codex",
keys: &[key("CHATGPT_CODEX_TOKEN", true, true)],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "github_copilot",
keys: &[],
oauth_cache_path: Some("githubcopilot/info.json"),
},
ProviderConfigDef {
id: "ollama",
keys: &[],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "local_inference",
keys: &[],
oauth_cache_path: None,
},
// Dictation providers (voice input)
ProviderConfigDef {
id: "dictation_groq",
keys: &[key("GROQ_API_KEY", true, true)],
oauth_cache_path: None,
},
ProviderConfigDef {
id: "dictation_elevenlabs",
keys: &[key("ELEVENLABS_API_KEY", true, true)],
oauth_cache_path: None,
},
];
pub(crate) fn find_config_key(key_name: &str) -> Option<&'static ConfigKey> {
PROVIDER_CONFIG_DEFS
.iter()
.flat_map(|def| def.keys.iter())
.find(|key| key.name == key_name)
}
pub(crate) fn find_provider_def(provider_id: &str) -> Option<&'static ProviderConfigDef> {
PROVIDER_CONFIG_DEFS.iter().find(|d| d.id == provider_id)
}