Skip to content

Commit 66b8f26

Browse files
committed
fix: enforce ACP global config directory invariant
Build Config::init_global from the same config path stack as Config::default and return an error when an existing global config points at a different writable path. Validate that invariant from GooseAcpAgent::new so direct ACP callers cannot silently use a stale global config. Update ACP fixtures to use one process-global test config directory and add a regression test for mismatched config directories. Signed-off-by: Matt Toohey <contact@matttoohey.com>
1 parent b15aa26 commit 66b8f26

8 files changed

Lines changed: 180 additions & 77 deletions

File tree

crates/goose/src/acp/server.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,8 @@ impl GooseAcpAgent {
951951
disable_session_naming: bool,
952952
goose_platform: GoosePlatform,
953953
) -> Result<Self> {
954+
Config::init_global(config_dir.clone())?;
955+
954956
let session_manager = Arc::new(SessionManager::new(data_dir));
955957

956958
// Eagerly initialize the SQLite pool so it's ready when providers/sessions need it.

crates/goose/src/acp/server_factory.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ impl AcpServer {
2121
}
2222

2323
pub async fn create_agent(&self) -> Result<Arc<GooseAcpAgent>> {
24-
let config = crate::config::Config::init_global(self.config.config_dir.clone());
24+
let config = crate::config::Config::init_global(self.config.config_dir.clone())?;
2525

2626
let goose_mode = config
2727
.get_goose_mode()

crates/goose/src/config/base.rs

Lines changed: 71 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,10 @@ pub enum ConfigError {
5252
KeyringError(String),
5353
#[error("Failed to lock config file: {0}")]
5454
LockError(String),
55+
#[error(
56+
"Global config already initialized with writable path {existing}; cannot use requested writable path {requested}"
57+
)]
58+
GlobalConfigPathMismatch { existing: String, requested: String },
5559
#[error("Secret stored using file-based fallback")]
5660
FallbackToFileStorage,
5761
}
@@ -163,43 +167,7 @@ fn bundled_defaults_path() -> Option<PathBuf> {
163167

164168
impl Default for Config {
165169
fn default() -> Self {
166-
let config_dir = Paths::config_dir();
167-
let user_config_path = config_dir.join(CONFIG_YAML_NAME);
168-
169-
let mut config_paths = vec![system_config_path()];
170-
if let Some(defaults) = bundled_defaults_path() {
171-
config_paths.insert(0, defaults);
172-
}
173-
config_paths.push(user_config_path.clone());
174-
175-
let no_secrets_config = Self {
176-
config_paths: config_paths.clone(),
177-
secrets: SecretStorage::File {
178-
path: Default::default(),
179-
},
180-
guard: Mutex::new(()),
181-
secrets_cache: Arc::new(Mutex::new(None)),
182-
};
183-
184-
let secrets = if env::var("GOOSE_DISABLE_KEYRING").is_ok()
185-
|| no_secrets_config
186-
.get_param::<serde_yaml::Value>("GOOSE_DISABLE_KEYRING")
187-
.is_ok_and(|v| keyring_disabled_value(&v))
188-
{
189-
SecretStorage::File {
190-
path: config_dir.join("secrets.yaml"),
191-
}
192-
} else {
193-
SecretStorage::Keyring {
194-
service: KEYRING_SERVICE.to_string(),
195-
}
196-
};
197-
Self {
198-
config_paths,
199-
secrets,
200-
guard: Mutex::new(()),
201-
secrets_cache: Arc::new(Mutex::new(None)),
202-
}
170+
Self::with_config_dir(Paths::config_dir())
203171
}
204172
}
205173

@@ -352,37 +320,77 @@ impl Config {
352320
GLOBAL_CONFIG.get_or_init(Config::default)
353321
}
354322

355-
/// Initialize the global configuration with a custom config directory.
356-
///
357-
/// If the global instance has already been initialized (e.g. by a prior call
358-
/// to `global()` or `init_global()`), this returns the existing instance and
359-
/// the provided path is ignored. This is safe because Goose runs one ACP
360-
/// server per process.
361-
pub fn init_global(config_dir: PathBuf) -> &'static Config {
362-
GLOBAL_CONFIG.get_or_init(|| {
363-
let config_path = config_dir.join(CONFIG_YAML_NAME);
364-
365-
let secrets = if env::var("GOOSE_DISABLE_KEYRING").is_ok()
366-
|| keyring_disabled_in_config(&config_path)
367-
{
368-
SecretStorage::File {
369-
path: config_dir.join("secrets.yaml"),
370-
}
371-
} else {
372-
SecretStorage::Keyring {
373-
service: KEYRING_SERVICE.to_string(),
374-
}
375-
};
323+
fn config_paths_for_dir(config_dir: &Path) -> Vec<PathBuf> {
324+
let mut config_paths = vec![system_config_path()];
325+
if let Some(defaults) = bundled_defaults_path() {
326+
config_paths.insert(0, defaults);
327+
}
328+
config_paths.push(config_dir.join(CONFIG_YAML_NAME));
329+
config_paths
330+
}
331+
332+
fn with_config_dir(config_dir: PathBuf) -> Self {
333+
let config_paths = Self::config_paths_for_dir(&config_dir);
334+
335+
let no_secrets_config = Self {
336+
config_paths: config_paths.clone(),
337+
secrets: SecretStorage::File {
338+
path: Default::default(),
339+
},
340+
guard: Mutex::new(()),
341+
secrets_cache: Arc::new(Mutex::new(None)),
342+
};
376343

377-
Config {
378-
config_path,
379-
secrets,
380-
guard: Mutex::new(()),
381-
secrets_cache: Arc::new(Mutex::new(None)),
344+
let secrets = if env::var("GOOSE_DISABLE_KEYRING").is_ok()
345+
|| no_secrets_config
346+
.get_param::<serde_yaml::Value>("GOOSE_DISABLE_KEYRING")
347+
.is_ok_and(|v| keyring_disabled_value(&v))
348+
{
349+
SecretStorage::File {
350+
path: config_dir.join("secrets.yaml"),
382351
}
352+
} else {
353+
SecretStorage::Keyring {
354+
service: KEYRING_SERVICE.to_string(),
355+
}
356+
};
357+
358+
Self {
359+
config_paths,
360+
secrets,
361+
guard: Mutex::new(()),
362+
secrets_cache: Arc::new(Mutex::new(None)),
363+
}
364+
}
365+
366+
fn ensure_writable_path(&self, requested: &Path) -> Result<(), ConfigError> {
367+
if self.write_path() == requested {
368+
return Ok(());
369+
}
370+
371+
Err(ConfigError::GlobalConfigPathMismatch {
372+
existing: self.write_path().display().to_string(),
373+
requested: requested.display().to_string(),
383374
})
384375
}
385376

377+
/// Initialize the global configuration with a custom config directory.
378+
///
379+
/// If the global instance has already been initialized, this validates that
380+
/// the requested config directory resolves to the same writable config path.
381+
pub fn init_global(config_dir: PathBuf) -> Result<&'static Config, ConfigError> {
382+
let requested = config_dir.join(CONFIG_YAML_NAME);
383+
384+
if let Some(config) = GLOBAL_CONFIG.get() {
385+
config.ensure_writable_path(&requested)?;
386+
return Ok(config);
387+
}
388+
389+
let config = GLOBAL_CONFIG.get_or_init(|| Self::with_config_dir(config_dir));
390+
config.ensure_writable_path(&requested)?;
391+
Ok(config)
392+
}
393+
386394
/// Create a new configuration instance with custom paths
387395
///
388396
/// This is primarily useful for testing or for applications that need

crates/goose/tests/acp_common_tests/mod.rs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -948,18 +948,19 @@ pub async fn run_permission_persistence<C: Connection>() {
948948
};
949949

950950
let mut conn = C::new(config, openai).await;
951+
let permission_path = conn.permission_config_path();
951952
let SessionData { mut session, .. } = conn.new_session().await.unwrap();
952953
expected_session_id.set(&session.session_id().0);
953954

954955
for (decision, expected_status, expected_yaml) in cases {
955956
conn.reset_openai();
956957
conn.reset_permissions();
957-
let _ = fs::remove_file(temp_dir.path().join("permission.yaml"));
958+
let _ = fs::remove_file(&permission_path);
958959
let output = session.prompt(prompt, decision).await.unwrap();
959960

960961
assert_eq!(output.tool_status.unwrap(), expected_status);
961962
assert_eq!(
962-
fs::read_to_string(temp_dir.path().join("permission.yaml")).unwrap_or_default(),
963+
fs::read_to_string(&permission_path).unwrap_or_default(),
963964
expected_yaml,
964965
);
965966
}

crates/goose/tests/acp_fixtures/mod.rs

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,8 @@ use sacp::schema::{
2323
};
2424
use std::collections::VecDeque;
2525
use std::future::Future;
26-
use std::path::PathBuf;
27-
use std::sync::{Arc, Mutex};
26+
use std::path::{Path, PathBuf};
27+
use std::sync::{Arc, Mutex, OnceLock};
2828
use tokio::task::JoinHandle;
2929
use tokio_util::compat::{TokioAsyncReadCompatExt, TokioAsyncWriteCompatExt};
3030
use wiremock::matchers::{method, path};
@@ -130,6 +130,41 @@ pub type DuplexTransport = sacp::ByteStreams<
130130
tokio_util::compat::Compat<tokio::io::DuplexStream>,
131131
>;
132132

133+
static ACP_TEST_CONFIG_DIR: OnceLock<tempfile::TempDir> = OnceLock::new();
134+
static ACP_TEST_LOCK: Mutex<()> = Mutex::new(());
135+
136+
fn acp_test_config_dir() -> PathBuf {
137+
if std::env::var_os("GOOSE_PATH_ROOT").is_some() {
138+
return Paths::config_dir();
139+
}
140+
141+
ACP_TEST_CONFIG_DIR
142+
.get_or_init(|| tempfile::tempdir().unwrap())
143+
.path()
144+
.to_path_buf()
145+
}
146+
147+
fn prepare_acp_config_dir(data_root: &Path, config_dir: &Path, current_model: &str) {
148+
fs::create_dir_all(config_dir).unwrap();
149+
150+
let source_config = data_root.join(goose::config::base::CONFIG_YAML_NAME);
151+
let target_config = config_dir.join(goose::config::base::CONFIG_YAML_NAME);
152+
153+
if source_config.exists() {
154+
if source_config != target_config {
155+
fs::copy(source_config, target_config).unwrap();
156+
}
157+
} else {
158+
fs::write(
159+
target_config,
160+
format!("GOOSE_MODEL: {current_model}\nGOOSE_PROVIDER: openai\n"),
161+
)
162+
.unwrap();
163+
}
164+
165+
let _ = fs::remove_file(config_dir.join("permission.yaml"));
166+
}
167+
133168
/// Wires up duplex streams, spawns `serve` for the given agent, and returns
134169
/// a ready-to-use sacp transport plus the server handle.
135170
#[allow(dead_code)]
@@ -161,14 +196,8 @@ pub async fn spawn_acp_server_in_process(
161196
fs::create_dir_all(data_root).unwrap();
162197
// TODO: Paths::in_state_dir is global, ignoring per-test data_root
163198
fs::create_dir_all(Paths::in_state_dir("logs")).unwrap();
164-
let config_path = data_root.join(goose::config::base::CONFIG_YAML_NAME);
165-
if !config_path.exists() {
166-
fs::write(
167-
&config_path,
168-
format!("GOOSE_MODEL: {current_model}\nGOOSE_PROVIDER: openai\n"),
169-
)
170-
.unwrap();
171-
}
199+
let config_dir = acp_test_config_dir();
200+
prepare_acp_config_dir(data_root, &config_dir, current_model);
172201
let provider_factory = provider_factory.unwrap_or_else(|| {
173202
let base_url = openai_base_url.to_string();
174203
Arc::new(move |_provider_name, model_config, _extensions| {
@@ -188,7 +217,7 @@ pub async fn spawn_acp_server_in_process(
188217
provider_factory,
189218
builtins.to_vec(),
190219
data_root.to_path_buf(),
191-
data_root.to_path_buf(),
220+
config_dir,
192221
goose_mode,
193222
true,
194223
GoosePlatform::GooseCli,
@@ -526,6 +555,7 @@ pub trait Connection: Sized {
526555
value: &str,
527556
) -> anyhow::Result<()>;
528557
fn data_root(&self) -> std::path::PathBuf;
558+
fn permission_config_path(&self) -> std::path::PathBuf;
529559
fn reset_openai(&self);
530560
fn reset_permissions(&self);
531561
}
@@ -556,6 +586,8 @@ where
556586
{
557587
register_builtin_extensions(goose_mcp::BUILTIN_EXTENSIONS.clone());
558588

589+
let _guard = ACP_TEST_LOCK.lock().unwrap_or_else(|err| err.into_inner());
590+
559591
let handle = std::thread::Builder::new()
560592
.name("acp-test".to_string())
561593
.stack_size(8 * 1024 * 1024)

crates/goose/tests/acp_fixtures/provider.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,10 @@ impl Connection for AcpProviderConnection {
287287
self.data_root.clone()
288288
}
289289

290+
fn permission_config_path(&self) -> std::path::PathBuf {
291+
self.permission_manager.get_config_path().to_path_buf()
292+
}
293+
290294
async fn set_mode(&self, _session_id: &str, _mode_id: &str) -> anyhow::Result<()> {
291295
Err(anyhow::anyhow!("not implemented for AcpProviderConnection"))
292296
}

crates/goose/tests/acp_fixtures/server.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -439,6 +439,10 @@ impl Connection for AcpServerConnection {
439439
self.data_root.clone()
440440
}
441441

442+
fn permission_config_path(&self) -> std::path::PathBuf {
443+
self.permission_manager.get_config_path().to_path_buf()
444+
}
445+
442446
fn reset_openai(&self) {
443447
self._openai.reset();
444448
}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
use goose::acp::server::{AcpProviderFactory, GooseAcpAgent};
2+
use goose::agents::GoosePlatform;
3+
use goose::config::base::CONFIG_YAML_NAME;
4+
use goose::config::{Config, GooseMode};
5+
use goose::providers::base::Provider;
6+
use std::sync::Arc;
7+
8+
#[tokio::test]
9+
async fn acp_agent_rejects_different_config_dir_after_global_initialization() {
10+
let first = tempfile::tempdir().unwrap();
11+
let second = tempfile::tempdir().unwrap();
12+
let data = tempfile::tempdir().unwrap();
13+
14+
let first_config = first.path().join(CONFIG_YAML_NAME);
15+
let second_config = second.path().join(CONFIG_YAML_NAME);
16+
17+
let config = Config::init_global(first.path().to_path_buf()).unwrap();
18+
assert_eq!(config.path(), first_config.display().to_string());
19+
20+
let provider_factory: AcpProviderFactory = Arc::new(|_, _, _| {
21+
Box::pin(async { Err::<Arc<dyn Provider>, _>(anyhow::anyhow!("provider not used")) })
22+
});
23+
24+
let err = match GooseAcpAgent::new(
25+
provider_factory,
26+
Vec::new(),
27+
data.path().to_path_buf(),
28+
second.path().to_path_buf(),
29+
GooseMode::Auto,
30+
true,
31+
GoosePlatform::GooseCli,
32+
)
33+
.await
34+
{
35+
Ok(_) => panic!("expected config directory mismatch"),
36+
Err(err) => err,
37+
};
38+
39+
let message = err.to_string();
40+
assert!(
41+
message.contains("Global config already initialized with writable path"),
42+
"{message}"
43+
);
44+
assert!(
45+
message.contains(&first_config.display().to_string()),
46+
"{message}"
47+
);
48+
assert!(
49+
message.contains(&second_config.display().to_string()),
50+
"{message}"
51+
);
52+
}

0 commit comments

Comments
 (0)