feat(acp): replace raw config and secret methods (#9000)

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
This commit is contained in:
Kalvin C
2026-05-04 18:24:28 -07:00
committed by GitHub
parent e82c4fdcb7
commit 2fe4c3d0bb
39 changed files with 1481 additions and 855 deletions
+60 -44
View File
@@ -173,69 +173,85 @@ pub struct GetSessionExtensionsResponse {
pub extensions: Vec<serde_json::Value>,
}
/// Read a single non-secret config value.
/// Read allowlisted user preferences. Empty `keys` means all supported preferences.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/config/read", response = ReadConfigResponse)]
#[request(method = "_goose/preferences/read", response = PreferencesReadResponse)]
#[serde(rename_all = "camelCase")]
pub struct ReadConfigRequest {
pub key: String,
pub struct PreferencesReadRequest {
#[serde(default)]
pub keys: Vec<PreferenceKey>,
}
/// Config read response.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
/// Save allowlisted user preferences.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/preferences/save", response = EmptyResponse)]
#[serde(rename_all = "camelCase")]
pub struct ReadConfigResponse {
pub struct PreferencesSaveRequest {
#[serde(default)]
pub values: Vec<PreferenceValue>,
}
/// Remove allowlisted user preferences.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/preferences/remove", response = EmptyResponse)]
#[serde(rename_all = "camelCase")]
pub struct PreferencesRemoveRequest {
#[serde(default)]
pub keys: Vec<PreferenceKey>,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub enum PreferenceKey {
#[default]
AutoCompactThreshold,
VoiceAutoSubmitPhrases,
VoiceDictationProvider,
VoiceDictationPreferredMic,
}
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct PreferenceValue {
pub key: PreferenceKey,
#[serde(default)]
pub value: serde_json::Value,
}
/// Upsert a single non-secret config value.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/config/upsert", response = EmptyResponse)]
#[serde(rename_all = "camelCase")]
pub struct UpsertConfigRequest {
pub key: String,
pub value: serde_json::Value,
}
/// Remove a single non-secret config value.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/config/remove", response = EmptyResponse)]
#[serde(rename_all = "camelCase")]
pub struct RemoveConfigRequest {
pub key: String,
}
/// Check whether a secret exists. Never returns the actual value.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/secret/check", response = CheckSecretResponse)]
#[serde(rename_all = "camelCase")]
pub struct CheckSecretRequest {
pub key: String,
}
/// Secret check response.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
#[serde(rename_all = "camelCase")]
pub struct CheckSecretResponse {
pub exists: bool,
pub struct PreferencesReadResponse {
pub values: Vec<PreferenceValue>,
}
/// Set a secret value (write-only).
/// Read Goose default provider and model configuration.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/secret/upsert", response = EmptyResponse)]
#[request(method = "_goose/defaults/read", response = DefaultsReadResponse)]
#[serde(rename_all = "camelCase")]
pub struct UpsertSecretRequest {
pub key: String,
pub value: serde_json::Value,
pub struct DefaultsReadRequest {}
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcResponse)]
#[serde(rename_all = "camelCase")]
pub struct DefaultsReadResponse {
pub provider_id: Option<String>,
pub model_id: Option<String>,
}
/// Remove a secret.
/// Set a dictation provider secret value.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/secret/remove", response = EmptyResponse)]
#[request(method = "_goose/dictation/secret/save", response = EmptyResponse)]
#[serde(rename_all = "camelCase")]
pub struct RemoveSecretRequest {
pub key: String,
pub struct DictationSecretSaveRequest {
pub provider: String,
pub value: String,
}
/// Remove a dictation provider secret value.
#[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, JsonRpcRequest)]
#[request(method = "_goose/dictation/secret/delete", response = EmptyResponse)]
#[serde(rename_all = "camelCase")]
pub struct DictationSecretDeleteRequest {
pub provider: String,
}
/// Update the project association for a session.
+20 -20
View File
@@ -121,34 +121,24 @@
"responseType": "ProviderConfigChangeResponse"
},
{
"method": "_goose/config/read",
"requestType": "ReadConfigRequest",
"responseType": "ReadConfigResponse"
"method": "_goose/preferences/read",
"requestType": "PreferencesReadRequest",
"responseType": "PreferencesReadResponse"
},
{
"method": "_goose/config/upsert",
"requestType": "UpsertConfigRequest",
"method": "_goose/preferences/save",
"requestType": "PreferencesSaveRequest",
"responseType": "EmptyResponse"
},
{
"method": "_goose/config/remove",
"requestType": "RemoveConfigRequest",
"method": "_goose/preferences/remove",
"requestType": "PreferencesRemoveRequest",
"responseType": "EmptyResponse"
},
{
"method": "_goose/secret/check",
"requestType": "CheckSecretRequest",
"responseType": "CheckSecretResponse"
},
{
"method": "_goose/secret/upsert",
"requestType": "UpsertSecretRequest",
"responseType": "EmptyResponse"
},
{
"method": "_goose/secret/remove",
"requestType": "RemoveSecretRequest",
"responseType": "EmptyResponse"
"method": "_goose/defaults/read",
"requestType": "DefaultsReadRequest",
"responseType": "DefaultsReadResponse"
},
{
"method": "_goose/session/export",
@@ -220,6 +210,16 @@
"requestType": "DictationConfigRequest",
"responseType": "DictationConfigResponse"
},
{
"method": "_goose/dictation/secret/save",
"requestType": "DictationSecretSaveRequest",
"responseType": "EmptyResponse"
},
{
"method": "_goose/dictation/secret/delete",
"requestType": "DictationSecretDeleteRequest",
"responseType": "EmptyResponse"
},
{
"method": "_goose/dictation/models/list",
"requestType": "DictationModelsListRequest",
+153 -125
View File
@@ -1246,118 +1246,114 @@
"x-side": "agent",
"x-method": "_goose/providers/config/delete"
},
"ReadConfigRequest": {
"PreferencesReadRequest": {
"type": "object",
"properties": {
"key": {
"type": "string"
"keys": {
"type": "array",
"items": {
"$ref": "#/$defs/PreferenceKey"
},
"default": []
}
},
"description": "Read allowlisted user preferences. Empty `keys` means all supported preferences.",
"x-side": "agent",
"x-method": "_goose/preferences/read"
},
"PreferenceKey": {
"type": "string",
"enum": [
"autoCompactThreshold",
"voiceAutoSubmitPhrases",
"voiceDictationProvider",
"voiceDictationPreferredMic"
]
},
"PreferencesReadResponse": {
"type": "object",
"properties": {
"values": {
"type": "array",
"items": {
"$ref": "#/$defs/PreferenceValue"
}
}
},
"required": [
"key"
"values"
],
"description": "Read a single non-secret config value.",
"x-side": "agent",
"x-method": "_goose/config/read"
"x-method": "_goose/preferences/read"
},
"ReadConfigResponse": {
"PreferenceValue": {
"type": "object",
"properties": {
"key": {
"$ref": "#/$defs/PreferenceKey"
},
"value": {
"default": null
}
},
"description": "Config read response.",
"x-side": "agent",
"x-method": "_goose/config/read"
"required": [
"key"
]
},
"UpsertConfigRequest": {
"PreferencesSaveRequest": {
"type": "object",
"properties": {
"key": {
"type": "string"
"values": {
"type": "array",
"items": {
"$ref": "#/$defs/PreferenceValue"
},
"default": []
}
},
"description": "Save allowlisted user preferences.",
"x-side": "agent",
"x-method": "_goose/preferences/save"
},
"PreferencesRemoveRequest": {
"type": "object",
"properties": {
"keys": {
"type": "array",
"items": {
"$ref": "#/$defs/PreferenceKey"
},
"default": []
}
},
"description": "Remove allowlisted user preferences.",
"x-side": "agent",
"x-method": "_goose/preferences/remove"
},
"DefaultsReadRequest": {
"type": "object",
"description": "Read Goose default provider and model configuration.",
"x-side": "agent",
"x-method": "_goose/defaults/read"
},
"DefaultsReadResponse": {
"type": "object",
"properties": {
"providerId": {
"type": [
"string",
"null"
]
},
"value": {}
},
"required": [
"key",
"value"
],
"description": "Upsert a single non-secret config value.",
"x-side": "agent",
"x-method": "_goose/config/upsert"
},
"RemoveConfigRequest": {
"type": "object",
"properties": {
"key": {
"type": "string"
"modelId": {
"type": [
"string",
"null"
]
}
},
"required": [
"key"
],
"description": "Remove a single non-secret config value.",
"x-side": "agent",
"x-method": "_goose/config/remove"
},
"CheckSecretRequest": {
"type": "object",
"properties": {
"key": {
"type": "string"
}
},
"required": [
"key"
],
"description": "Check whether a secret exists. Never returns the actual value.",
"x-side": "agent",
"x-method": "_goose/secret/check"
},
"CheckSecretResponse": {
"type": "object",
"properties": {
"exists": {
"type": "boolean"
}
},
"required": [
"exists"
],
"description": "Secret check response.",
"x-side": "agent",
"x-method": "_goose/secret/check"
},
"UpsertSecretRequest": {
"type": "object",
"properties": {
"key": {
"type": "string"
},
"value": {}
},
"required": [
"key",
"value"
],
"description": "Set a secret value (write-only).",
"x-side": "agent",
"x-method": "_goose/secret/upsert"
},
"RemoveSecretRequest": {
"type": "object",
"properties": {
"key": {
"type": "string"
}
},
"required": [
"key"
],
"description": "Remove a secret.",
"x-side": "agent",
"x-method": "_goose/secret/remove"
"x-method": "_goose/defaults/read"
},
"ExportSessionRequest": {
"type": "object",
@@ -1920,6 +1916,38 @@
"description"
]
},
"DictationSecretSaveRequest": {
"type": "object",
"properties": {
"provider": {
"type": "string"
},
"value": {
"type": "string"
}
},
"required": [
"provider",
"value"
],
"description": "Set a dictation provider secret value.",
"x-side": "agent",
"x-method": "_goose/dictation/secret/save"
},
"DictationSecretDeleteRequest": {
"type": "object",
"properties": {
"provider": {
"type": "string"
}
},
"required": [
"provider"
],
"description": "Remove a dictation provider secret value.",
"x-side": "agent",
"x-method": "_goose/dictation/secret/delete"
},
"DictationModelsListRequest": {
"type": "object",
"description": "List available local Whisper models with their download status.",
@@ -2330,56 +2358,38 @@
{
"allOf": [
{
"$ref": "#/$defs/ReadConfigRequest"
"$ref": "#/$defs/PreferencesReadRequest"
}
],
"description": "Params for _goose/config/read",
"title": "ReadConfigRequest"
"description": "Params for _goose/preferences/read",
"title": "PreferencesReadRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/UpsertConfigRequest"
"$ref": "#/$defs/PreferencesSaveRequest"
}
],
"description": "Params for _goose/config/upsert",
"title": "UpsertConfigRequest"
"description": "Params for _goose/preferences/save",
"title": "PreferencesSaveRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/RemoveConfigRequest"
"$ref": "#/$defs/PreferencesRemoveRequest"
}
],
"description": "Params for _goose/config/remove",
"title": "RemoveConfigRequest"
"description": "Params for _goose/preferences/remove",
"title": "PreferencesRemoveRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/CheckSecretRequest"
"$ref": "#/$defs/DefaultsReadRequest"
}
],
"description": "Params for _goose/secret/check",
"title": "CheckSecretRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/UpsertSecretRequest"
}
],
"description": "Params for _goose/secret/upsert",
"title": "UpsertSecretRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/RemoveSecretRequest"
}
],
"description": "Params for _goose/secret/remove",
"title": "RemoveSecretRequest"
"description": "Params for _goose/defaults/read",
"title": "DefaultsReadRequest"
},
{
"allOf": [
@@ -2507,6 +2517,24 @@
"description": "Params for _goose/dictation/config",
"title": "DictationConfigRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/DictationSecretSaveRequest"
}
],
"description": "Params for _goose/dictation/secret/save",
"title": "DictationSecretSaveRequest"
},
{
"allOf": [
{
"$ref": "#/$defs/DictationSecretDeleteRequest"
}
],
"description": "Params for _goose/dictation/secret/delete",
"title": "DictationSecretDeleteRequest"
},
{
"allOf": [
{
@@ -2730,18 +2758,18 @@
{
"allOf": [
{
"$ref": "#/$defs/ReadConfigResponse"
"$ref": "#/$defs/PreferencesReadResponse"
}
],
"title": "ReadConfigResponse"
"title": "PreferencesReadResponse"
},
{
"allOf": [
{
"$ref": "#/$defs/CheckSecretResponse"
"$ref": "#/$defs/DefaultsReadResponse"
}
],
"title": "CheckSecretResponse"
"title": "DefaultsReadResponse"
},
{
"allOf": [
-1
View File
@@ -74,7 +74,6 @@ mod dispatch;
mod extensions;
mod providers;
mod resources;
mod secrets;
mod sessions;
mod sources;
mod tools;
+153 -16
View File
@@ -1,36 +1,173 @@
use super::*;
impl GooseAcpAgent {
pub(super) async fn on_read_config(
pub(super) async fn on_preferences_read(
&self,
req: ReadConfigRequest,
) -> Result<ReadConfigResponse, sacp::Error> {
req: PreferencesReadRequest,
) -> Result<PreferencesReadResponse, sacp::Error> {
let config = self.config()?;
let response = match config.get_param::<serde_json::Value>(&req.key) {
Ok(value) => ReadConfigResponse { value },
Err(crate::config::ConfigError::NotFound(_)) => ReadConfigResponse {
value: serde_json::Value::Null,
},
Err(e) => return Err(sacp::Error::internal_error().data(e.to_string())),
let keys = if req.keys.is_empty() {
PREFERENCE_DEFS.iter().map(|def| def.key).collect()
} else {
req.keys
};
Ok(response)
let mut values = Vec::with_capacity(keys.len());
for key in keys {
let def = preference_def(key)?;
let value = match config.get_param::<serde_json::Value>(def.config_key) {
Ok(value) => value,
Err(crate::config::ConfigError::NotFound(_)) => serde_json::Value::Null,
Err(e) => return Err(sacp::Error::internal_error().data(e.to_string())),
};
values.push(PreferenceValue { key, value });
}
Ok(PreferencesReadResponse { values })
}
pub(super) async fn on_upsert_config(
pub(super) async fn on_preferences_save(
&self,
req: UpsertConfigRequest,
req: PreferencesSaveRequest,
) -> Result<EmptyResponse, sacp::Error> {
let config = self.config()?;
config.set_param(&req.key, &req.value).internal_err()?;
let mut updates = Vec::with_capacity(req.values.len());
for preference in &req.values {
let def = preference_def(preference.key)?;
(def.validate)(&preference.value)?;
updates.push((def.config_key.to_string(), preference.value.clone()));
}
config.set_param_values(&updates).internal_err()?;
Ok(EmptyResponse {})
}
pub(super) async fn on_remove_config(
pub(super) async fn on_preferences_remove(
&self,
req: RemoveConfigRequest,
req: PreferencesRemoveRequest,
) -> Result<EmptyResponse, sacp::Error> {
let config = self.config()?;
config.delete(&req.key).internal_err()?;
for key in req.keys {
let def = preference_def(key)?;
config.delete(def.config_key).internal_err()?;
}
Ok(EmptyResponse {})
}
pub(super) async fn on_defaults_read(
&self,
_req: DefaultsReadRequest,
) -> Result<DefaultsReadResponse, sacp::Error> {
let config = self.config()?;
Ok(DefaultsReadResponse {
provider_id: optional_config_string(&config, "GOOSE_PROVIDER")?,
model_id: optional_config_string(&config, "GOOSE_MODEL")?,
})
}
}
struct PreferenceDef {
key: PreferenceKey,
config_key: &'static str,
validate: fn(&serde_json::Value) -> Result<(), sacp::Error>,
}
const PREFERENCE_DEFS: &[PreferenceDef] = &[
PreferenceDef {
key: PreferenceKey::AutoCompactThreshold,
config_key: "GOOSE_AUTO_COMPACT_THRESHOLD",
validate: validate_auto_compact_threshold,
},
PreferenceDef {
key: PreferenceKey::VoiceAutoSubmitPhrases,
config_key: "VOICE_AUTO_SUBMIT_PHRASES",
validate: validate_voice_auto_submit_phrases,
},
PreferenceDef {
key: PreferenceKey::VoiceDictationProvider,
config_key: "VOICE_DICTATION_PROVIDER",
validate: validate_voice_dictation_provider,
},
PreferenceDef {
key: PreferenceKey::VoiceDictationPreferredMic,
config_key: "VOICE_DICTATION_PREFERRED_MIC",
validate: validate_voice_dictation_preferred_mic,
},
];
fn preference_def(key: PreferenceKey) -> Result<&'static PreferenceDef, sacp::Error> {
PREFERENCE_DEFS
.iter()
.find(|def| def.key == key)
.ok_or_else(|| {
sacp::Error::internal_error().data(format!("Missing preference definition for {key:?}"))
})
}
fn validate_auto_compact_threshold(value: &serde_json::Value) -> Result<(), sacp::Error> {
let Some(value) = value.as_f64() else {
return Err(sacp::Error::invalid_params().data("autoCompactThreshold must be a number"));
};
if !value.is_finite() || value <= 0.0 || value > 1.0 {
return Err(sacp::Error::invalid_params()
.data("autoCompactThreshold must be greater than 0 and at most 1"));
}
Ok(())
}
fn validate_voice_auto_submit_phrases(value: &serde_json::Value) -> Result<(), sacp::Error> {
if !value.is_string() {
return Err(sacp::Error::invalid_params().data("voiceAutoSubmitPhrases must be a string"));
}
Ok(())
}
fn validate_voice_dictation_provider(value: &serde_json::Value) -> Result<(), sacp::Error> {
let Some(value) = value.as_str() else {
return Err(sacp::Error::invalid_params().data("voiceDictationProvider must be a string"));
};
if !is_supported_voice_dictation_provider(value) {
return Err(sacp::Error::invalid_params().data("voiceDictationProvider is not supported"));
}
Ok(())
}
fn validate_voice_dictation_preferred_mic(value: &serde_json::Value) -> Result<(), sacp::Error> {
let Some(value) = value.as_str() else {
return Err(
sacp::Error::invalid_params().data("voiceDictationPreferredMic must be a string")
);
};
if value.is_empty() {
return Err(
sacp::Error::invalid_params().data("voiceDictationPreferredMic must be non-empty")
);
}
Ok(())
}
fn is_supported_voice_dictation_provider(value: &str) -> bool {
matches!(value, "openai" | "groq" | "elevenlabs" | "__disabled__") || {
#[cfg(feature = "local-inference")]
{
value == "local"
}
#[cfg(not(feature = "local-inference"))]
{
false
}
}
}
fn optional_config_string(config: &Config, key: &str) -> Result<Option<String>, sacp::Error> {
match config.get_param::<String>(key) {
Ok(value) => Ok(Some(value)),
Err(crate::config::ConfigError::NotFound(_)) => Ok(None),
Err(e) => Err(sacp::Error::internal_error().data(e.to_string())),
}
}
+34 -34
View File
@@ -200,52 +200,36 @@ impl GooseAcpAgent {
self.on_delete_provider_config(req).await
}
#[custom_method(ReadConfigRequest)]
async fn dispatch_read_config(
#[custom_method(PreferencesReadRequest)]
async fn dispatch_preferences_read(
&self,
req: ReadConfigRequest,
) -> Result<ReadConfigResponse, sacp::Error> {
self.on_read_config(req).await
req: PreferencesReadRequest,
) -> Result<PreferencesReadResponse, sacp::Error> {
self.on_preferences_read(req).await
}
#[custom_method(UpsertConfigRequest)]
async fn dispatch_upsert_config(
#[custom_method(PreferencesSaveRequest)]
async fn dispatch_preferences_save(
&self,
req: UpsertConfigRequest,
req: PreferencesSaveRequest,
) -> Result<EmptyResponse, sacp::Error> {
self.on_upsert_config(req).await
self.on_preferences_save(req).await
}
#[custom_method(RemoveConfigRequest)]
async fn dispatch_remove_config(
#[custom_method(PreferencesRemoveRequest)]
async fn dispatch_preferences_remove(
&self,
req: RemoveConfigRequest,
req: PreferencesRemoveRequest,
) -> Result<EmptyResponse, sacp::Error> {
self.on_remove_config(req).await
self.on_preferences_remove(req).await
}
#[custom_method(CheckSecretRequest)]
async fn dispatch_check_secret(
#[custom_method(DefaultsReadRequest)]
async fn dispatch_defaults_read(
&self,
req: CheckSecretRequest,
) -> Result<CheckSecretResponse, sacp::Error> {
self.on_check_secret(req).await
}
#[custom_method(UpsertSecretRequest)]
async fn dispatch_upsert_secret(
&self,
req: UpsertSecretRequest,
) -> Result<EmptyResponse, sacp::Error> {
self.on_upsert_secret(req).await
}
#[custom_method(RemoveSecretRequest)]
async fn dispatch_remove_secret(
&self,
req: RemoveSecretRequest,
) -> Result<EmptyResponse, sacp::Error> {
self.on_remove_secret(req).await
req: DefaultsReadRequest,
) -> Result<DefaultsReadResponse, sacp::Error> {
self.on_defaults_read(req).await
}
#[custom_method(ExportSessionRequest)]
@@ -360,6 +344,22 @@ impl GooseAcpAgent {
self.on_dictation_config(_req).await
}
#[custom_method(DictationSecretSaveRequest)]
async fn dispatch_dictation_secret_save(
&self,
req: DictationSecretSaveRequest,
) -> Result<EmptyResponse, sacp::Error> {
self.on_dictation_secret_save(req).await
}
#[custom_method(DictationSecretDeleteRequest)]
async fn dispatch_dictation_secret_delete(
&self,
req: DictationSecretDeleteRequest,
) -> Result<EmptyResponse, sacp::Error> {
self.on_dictation_secret_delete(req).await
}
#[custom_method(DictationModelsListRequest)]
async fn dispatch_dictation_models_list(
&self,
+48 -1
View File
@@ -2,7 +2,7 @@ use super::*;
#[cfg(feature = "local-inference")]
use crate::dictation::providers::transcribe_local;
use crate::dictation::providers::{
all_providers, is_configured, transcribe_with_provider, DictationProvider,
all_providers, get_provider_def, is_configured, transcribe_with_provider, DictationProvider,
};
#[cfg(feature = "local-inference")]
use crate::dictation::whisper;
@@ -125,6 +125,30 @@ impl GooseAcpAgent {
Ok(DictationConfigResponse { providers })
}
pub(super) async fn on_dictation_secret_save(
&self,
req: DictationSecretSaveRequest,
) -> Result<EmptyResponse, sacp::Error> {
let provider = parse_dictation_provider(&req.provider)?;
let key = dictation_secret_config_key(provider)?;
let config = self.config()?;
config.set_secret(key, &req.value).internal_err()?;
Config::global().invalidate_secrets_cache();
Ok(EmptyResponse {})
}
pub(super) async fn on_dictation_secret_delete(
&self,
req: DictationSecretDeleteRequest,
) -> Result<EmptyResponse, sacp::Error> {
let provider = parse_dictation_provider(&req.provider)?;
let key = dictation_secret_config_key(provider)?;
let config = self.config()?;
config.delete_secret(key).internal_err()?;
Config::global().invalidate_secrets_cache();
Ok(EmptyResponse {})
}
pub(super) async fn on_dictation_models_list(
&self,
_req: DictationModelsListRequest,
@@ -321,6 +345,29 @@ impl GooseAcpAgent {
Ok(EmptyResponse {})
}
}
fn parse_dictation_provider(provider: &str) -> Result<DictationProvider, sacp::Error> {
serde_json::from_value(serde_json::Value::String(provider.to_string()))
.map_err(|_| sacp::Error::invalid_params().data(format!("Unknown provider: {provider}")))
}
fn dictation_secret_config_key(provider: DictationProvider) -> Result<&'static str, sacp::Error> {
let def = get_provider_def(provider);
if def.uses_provider_config {
return Err(sacp::Error::invalid_params().data(
"Dictation provider uses the main provider configuration. Configure its credentials in provider settings instead.",
));
}
#[cfg(feature = "local-inference")]
if provider == DictationProvider::Local {
return Err(sacp::Error::invalid_params()
.data("Dictation provider does not use an API key or secret."));
}
Ok(def.config_key)
}
fn dictation_model_config_key(provider: DictationProvider) -> Option<String> {
match provider {
DictationProvider::OpenAI => Some(OPENAI_TRANSCRIPTION_MODEL_CONFIG_KEY.to_string()),
-32
View File
@@ -1,32 +0,0 @@
use super::*;
impl GooseAcpAgent {
pub(super) async fn on_check_secret(
&self,
req: CheckSecretRequest,
) -> Result<CheckSecretResponse, sacp::Error> {
let config = self.config()?;
let exists = config.get_secret::<serde_json::Value>(&req.key).is_ok();
Ok(CheckSecretResponse { exists })
}
pub(super) async fn on_upsert_secret(
&self,
req: UpsertSecretRequest,
) -> Result<EmptyResponse, sacp::Error> {
let config = self.config()?;
config.set_secret(&req.key, &req.value).internal_err()?;
Config::global().invalidate_secrets_cache();
Ok(EmptyResponse {})
}
pub(super) async fn on_remove_secret(
&self,
req: RemoveSecretRequest,
) -> Result<EmptyResponse, sacp::Error> {
let config = self.config()?;
config.delete_secret(&req.key).internal_err()?;
Config::global().invalidate_secrets_cache();
Ok(EmptyResponse {})
}
}
+14
View File
@@ -719,6 +719,20 @@ impl Config {
self.save_values(&values)
}
/// Set multiple configuration values in the config file with one read and one write.
pub fn set_param_values(&self, updates: &[(String, Value)]) -> Result<(), ConfigError> {
if updates.is_empty() {
return Ok(());
}
let _guard = self.guard.lock().unwrap();
let mut values = self.load_write_config()?;
for (key, value) in updates {
values.insert(serde_yaml::to_value(key)?, serde_yaml::to_value(value)?);
}
self.save_values(&values)
}
/// Delete a configuration value in the config file.
///
/// This will immediately write the value to the config file. The value
+277 -41
View File
@@ -69,7 +69,7 @@ fn mock_provider_factory() -> AcpProviderFactory {
#[test]
fn test_custom_get_tools() {
run_test(async {
run_test(async move {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let mut conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
@@ -92,7 +92,7 @@ fn test_custom_get_tools() {
#[test]
fn test_custom_get_extensions() {
run_test(async {
run_test(async move {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
@@ -140,53 +140,289 @@ fn test_custom_provider_inventory_includes_metadata() {
}
#[test]
fn test_custom_config_crud() {
fn test_custom_preferences_read_save_remove() {
run_test(async {
let data_root = tempfile::tempdir().unwrap();
std::fs::write(
data_root
.path()
.join(goose::config::base::CONFIG_YAML_NAME),
"GOOSE_MODEL: gpt-4o\nGOOSE_PROVIDER: openai\nGOOSE_AUTO_COMPACT_THRESHOLD: 0.7\nVOICE_AUTO_SUBMIT_PHRASES: send it\n",
)
.unwrap();
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let config = TestConnectionConfig {
data_root: data_root.path().to_path_buf(),
..Default::default()
};
let conn = AcpServerConnection::new(config, openai).await;
let response = send_custom(
conn.cx(),
"_goose/preferences/read",
serde_json::json!({
"keys": [
"autoCompactThreshold",
"voiceAutoSubmitPhrases",
"voiceDictationPreferredMic"
],
}),
)
.await
.expect("preferences read should succeed");
assert_eq!(
response.get("values"),
Some(&serde_json::json!([
{ "key": "autoCompactThreshold", "value": 0.7 },
{ "key": "voiceAutoSubmitPhrases", "value": "send it" },
{ "key": "voiceDictationPreferredMic", "value": null },
]))
);
send_custom(
conn.cx(),
"_goose/preferences/save",
serde_json::json!({
"values": [
{ "key": "voiceDictationProvider", "value": "__disabled__" },
{ "key": "voiceDictationPreferredMic", "value": "mic-1" }
],
}),
)
.await
.expect("preferences save should succeed");
send_custom(
conn.cx(),
"_goose/preferences/remove",
serde_json::json!({
"keys": ["voiceDictationProvider"],
}),
)
.await
.expect("preferences remove should succeed");
let response = send_custom(
conn.cx(),
"_goose/preferences/read",
serde_json::json!({
"keys": ["voiceDictationProvider", "voiceDictationPreferredMic"],
}),
)
.await
.expect("preferences read after remove should succeed");
assert_eq!(
response.get("values"),
Some(&serde_json::json!([
{ "key": "voiceDictationProvider", "value": null },
{ "key": "voiceDictationPreferredMic", "value": "mic-1" },
]))
);
});
}
#[test]
fn test_custom_preferences_save_rejects_invalid_values() {
run_test(async {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
let invalid_payloads = [
serde_json::json!({
"values": [{ "key": "autoCompactThreshold", "value": 0 }],
}),
serde_json::json!({
"values": [{ "key": "autoCompactThreshold", "value": 1.1 }],
}),
serde_json::json!({
"values": [{ "key": "voiceAutoSubmitPhrases", "value": ["send"] }],
}),
serde_json::json!({
"values": [{ "key": "voiceDictationProvider", "value": "bogus" }],
}),
serde_json::json!({
"values": [{ "key": "voiceDictationPreferredMic", "value": "" }],
}),
];
for payload in invalid_payloads {
let result = send_custom(conn.cx(), "_goose/preferences/save", payload).await;
assert!(result.is_err(), "expected invalid params error");
}
let result = send_custom(
conn.cx(),
"_goose/preferences/save",
serde_json::json!({
"values": [
{ "key": "voiceDictationPreferredMic", "value": "mic-1" },
{ "key": "voiceDictationProvider", "value": "bogus" }
],
}),
)
.await;
assert!(result.is_err(), "expected invalid params error");
let response = send_custom(
conn.cx(),
"_goose/preferences/read",
serde_json::json!({
"keys": ["voiceDictationPreferredMic"],
}),
)
.await
.expect("preferences read should succeed");
assert_eq!(
response.get("values"),
Some(&serde_json::json!([
{ "key": "voiceDictationPreferredMic", "value": null },
]))
);
});
}
#[test]
fn test_custom_defaults_read() {
run_test(async {
let data_root = tempfile::tempdir().unwrap();
std::fs::write(
data_root.path().join(goose::config::base::CONFIG_YAML_NAME),
"GOOSE_MODEL: claude-3-5-haiku-latest\nGOOSE_PROVIDER: anthropic\n",
)
.unwrap();
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let config = TestConnectionConfig {
data_root: data_root.path().to_path_buf(),
..Default::default()
};
let conn = AcpServerConnection::new(config, openai).await;
let response = send_custom(conn.cx(), "_goose/defaults/read", serde_json::json!({}))
.await
.expect("defaults read should succeed");
assert_eq!(
response,
serde_json::json!({
"providerId": "anthropic",
"modelId": "claude-3-5-haiku-latest",
})
);
});
}
#[test]
fn test_custom_dictation_secret_save_delete() {
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")),
("GROQ_API_KEY", None::<&str>),
]);
let config_dir = goose::config::paths::Paths::config_dir();
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();
run_test(async move {
let openai = 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;
send_custom(
conn.cx(),
"_goose/dictation/secret/save",
serde_json::json!({
"provider": "groq",
"value": "groq-key",
}),
)
.await
.expect("dictation secret save should succeed");
let config = send_custom(conn.cx(), "_goose/dictation/config", serde_json::json!({}))
.await
.expect("dictation config should succeed");
assert_eq!(
config
.pointer("/providers/groq/configured")
.and_then(|value| value.as_bool()),
Some(true)
);
let provider_config_result = send_custom(
conn.cx(),
"_goose/dictation/secret/save",
serde_json::json!({
"provider": "openai",
"value": "openai-key",
}),
)
.await;
assert!(
provider_config_result.is_err(),
"provider-config dictation providers should be rejected"
);
let unknown_result = send_custom(
conn.cx(),
"_goose/dictation/secret/save",
serde_json::json!({
"provider": "unknown",
"value": "key",
}),
)
.await;
assert!(
unknown_result.is_err(),
"unknown provider should be rejected"
);
send_custom(
conn.cx(),
"_goose/dictation/secret/delete",
serde_json::json!({
"provider": "groq",
}),
)
.await
.expect("dictation secret delete should succeed");
let config = send_custom(conn.cx(), "_goose/dictation/config", serde_json::json!({}))
.await
.expect("dictation config should succeed");
assert_eq!(
config
.pointer("/providers/groq/configured")
.and_then(|value| value.as_bool()),
Some(false)
);
});
}
#[test]
fn test_raw_config_and_secret_methods_are_removed() {
run_test(async {
let openai = OpenAiFixture::new(vec![], Arc::new(EnforceSessionId::default())).await;
let conn = AcpServerConnection::new(TestConnectionConfig::default(), openai).await;
for method in [
"_goose/config/read",
"_goose/config/upsert",
serde_json::json!({
"key": "GOOSE_PROVIDER",
"value": "anthropic",
}),
)
.await
.expect("config upsert should succeed");
let response = send_custom(
conn.cx(),
"_goose/config/read",
serde_json::json!({
"key": "GOOSE_PROVIDER",
}),
)
.await
.expect("config read should succeed");
assert_eq!(response.get("value"), Some(&serde_json::json!("anthropic")));
send_custom(
conn.cx(),
"_goose/config/remove",
serde_json::json!({
"key": "GOOSE_PROVIDER",
}),
)
.await
.expect("config remove should succeed");
let response = send_custom(
conn.cx(),
"_goose/config/read",
serde_json::json!({
"key": "GOOSE_PROVIDER",
}),
)
.await
.expect("config read after remove should succeed");
assert_eq!(response.get("value"), Some(&serde_json::Value::Null));
"_goose/secret/check",
"_goose/secret/upsert",
"_goose/secret/remove",
] {
let result = send_custom(conn.cx(), method, serde_json::json!({})).await;
assert!(result.is_err(), "{method} should be removed");
}
});
}
@@ -79,6 +79,7 @@ fn acp_secret_mutations_and_inventory_refresh_invalidate_global_secret_cache() {
("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),
@@ -87,12 +88,12 @@ fn acp_secret_mutations_and_inventory_refresh_invalidate_global_secret_cache() {
let config_dir = Paths::config_dir();
let data_dir = Paths::data_dir();
write_config(&config_dir);
write_secrets(&config_dir, "OPENAI_API_KEY: stale-key\n");
write_secrets(&config_dir, "GROQ_API_KEY: stale-key\n");
run_test(async move {
assert_eq!(
Config::global()
.get_secret::<String>("OPENAI_API_KEY")
.get_secret::<String>("GROQ_API_KEY")
.unwrap(),
"stale-key"
);
@@ -109,43 +110,43 @@ fn acp_secret_mutations_and_inventory_refresh_invalidate_global_secret_cache() {
};
let conn = AcpServerConnection::new(config, openai).await;
write_secrets(&config_dir, "OPENAI_API_KEY: fresh-key\n");
write_secrets(&config_dir, "GROQ_API_KEY: fresh-key\n");
send_custom(
conn.cx(),
"_goose/secret/upsert",
"_goose/dictation/secret/save",
serde_json::json!({
"key": "OPENAI_API_KEY",
"provider": "groq",
"value": "fresh-key",
}),
)
.await
.expect("secret upsert should succeed");
.expect("dictation secret save should succeed");
assert_eq!(
Config::global()
.get_secret::<String>("OPENAI_API_KEY")
.get_secret::<String>("GROQ_API_KEY")
.unwrap(),
"fresh-key",
"ACP secret upsert should invalidate the global secrets cache"
"ACP dictation secret save should invalidate the global secrets cache"
);
write_secrets(&config_dir, "{}\n");
send_custom(
conn.cx(),
"_goose/secret/remove",
"_goose/dictation/secret/delete",
serde_json::json!({
"key": "OPENAI_API_KEY",
"provider": "groq",
}),
)
.await
.expect("secret remove should succeed");
.expect("dictation secret delete should succeed");
assert!(
matches!(
Config::global().get_secret::<String>("OPENAI_API_KEY"),
Config::global().get_secret::<String>("GROQ_API_KEY"),
Err(ConfigError::NotFound(_))
),
"ACP secret remove should invalidate the global secrets cache"
"ACP dictation secret delete should invalidate the global secrets cache"
);
let save_provider_config = send_custom(