mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-11 01:51:34 -10:00
Merge remote-tracking branch 'origin/pr-5075' into agent/v094-release-train-20260802
Signed-off-by: Hmbown <[email protected]> # Conflicts: # crates/config/src/lib.rs # crates/tui/src/config.rs
This commit is contained in:
commit
83e7477d0a
22 files changed
+557
-473
No files matched your search
+24
-34
@@ -2058,18 +2058,6 @@ fn no_keyring_secrets() -> Secrets {
|
||||
))
|
||||
}
|
||||
|
||||
fn write_provider_api_key_to_config(
|
||||
store: &mut ConfigStore,
|
||||
provider: ProviderKind,
|
||||
api_key: &str,
|
||||
) {
|
||||
prepare_provider_api_key_metadata(store, provider);
|
||||
store.config.providers.for_provider_mut(provider).api_key = Some(api_key.to_string());
|
||||
if provider == ProviderKind::Deepseek {
|
||||
store.config.api_key = Some(api_key.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: ProviderKind) {
|
||||
store.config.auth_mode = Some("api_key".to_string());
|
||||
let provider_config = store.config.providers.for_provider_mut(provider);
|
||||
@@ -2091,8 +2079,8 @@ fn prepare_provider_api_key_metadata(store: &mut ConfigStore, provider: Provider
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist a provider credential to the durable secret store first. A
|
||||
/// plaintext config slot is used only when that write fails.
|
||||
/// Persist a provider credential to the durable secret store without silently
|
||||
/// downgrading a backend failure to plaintext config storage.
|
||||
fn persist_provider_api_key(
|
||||
store: &mut ConfigStore,
|
||||
secrets: &Secrets,
|
||||
@@ -2118,7 +2106,7 @@ fn persist_provider_api_key_unlocked(
|
||||
let slot = provider_slot(provider);
|
||||
// A readable prior value is required before a secret-store write so a
|
||||
// later config failure can restore the exact prior state. If the backend
|
||||
// cannot provide that snapshot, use the owner-only config fallback.
|
||||
// cannot provide that snapshot, fail before changing the config file.
|
||||
let prior_secret = secrets.get(slot);
|
||||
let secret_store_saved = match prior_secret.as_ref().map_err(|error| error.to_string()) {
|
||||
Ok(_) => match secrets.set(slot, api_key) {
|
||||
@@ -2127,20 +2115,19 @@ fn persist_provider_api_key_unlocked(
|
||||
true
|
||||
}
|
||||
Err(err) => {
|
||||
eprintln!(
|
||||
"warning: secret-store write failed for {}; using owner-only config fallback: {err}",
|
||||
provider_slot(provider)
|
||||
);
|
||||
write_provider_api_key_to_config(store, provider, api_key);
|
||||
false
|
||||
store.config = original_config;
|
||||
return Err(anyhow::anyhow!(
|
||||
"Secret storage write failed for {slot}: {err}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
|
||||
codewhale_config::quote_os_path(store.path())
|
||||
));
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
eprintln!(
|
||||
"warning: secret-store snapshot failed for {slot}; using owner-only config fallback: {error}"
|
||||
);
|
||||
write_provider_api_key_to_config(store, provider, api_key);
|
||||
false
|
||||
store.config = original_config;
|
||||
return Err(anyhow::anyhow!(
|
||||
"Secret storage snapshot failed for {slot}: {error}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
|
||||
codewhale_config::quote_os_path(store.path())
|
||||
));
|
||||
}
|
||||
};
|
||||
if let Err(error) = store.save() {
|
||||
@@ -6223,7 +6210,7 @@ model = "qwen-2.5-7b"
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auth_set_uses_plaintext_config_only_when_secret_store_write_fails() {
|
||||
fn auth_set_refuses_plaintext_config_when_secret_store_write_fails() {
|
||||
use codewhale_secrets::{KeyringStore, SecretsError};
|
||||
use std::sync::Arc;
|
||||
|
||||
@@ -6252,7 +6239,7 @@ model = "qwen-2.5-7b"
|
||||
let mut store = ConfigStore::load(Some(path.clone())).expect("load config");
|
||||
let secrets = Secrets::new(Arc::new(FailingStore));
|
||||
|
||||
run_auth_command_with_secrets(
|
||||
let error = run_auth_command_with_secrets(
|
||||
&mut store,
|
||||
AuthCommand::Set {
|
||||
provider: ProviderArg::Openrouter,
|
||||
@@ -6261,14 +6248,17 @@ model = "qwen-2.5-7b"
|
||||
},
|
||||
&secrets,
|
||||
)
|
||||
.expect("config fallback");
|
||||
.expect_err("secret-store failure must not downgrade to plaintext");
|
||||
|
||||
assert_eq!(
|
||||
store.config.providers.openrouter.api_key.as_deref(),
|
||||
Some("fallback-test-credential")
|
||||
let message = format!("{error:#}");
|
||||
assert!(message.contains("Secret storage write failed"), "{message}");
|
||||
assert!(message.contains("Refusing"), "{message}");
|
||||
assert!(
|
||||
message.contains(&codewhale_config::quote_os_path(store.path())),
|
||||
"{message}"
|
||||
);
|
||||
let saved = std::fs::read_to_string(path).expect("config fallback file");
|
||||
assert!(saved.contains("fallback-test-credential"));
|
||||
assert!(store.config.providers.openrouter.api_key.is_none());
|
||||
assert!(!path.exists(), "plaintext config must stay untouched");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -827,6 +827,8 @@ fn deepseek_home() -> PathBuf {
|
||||
// but delegates every environment and platform-home decision to the shared
|
||||
// runtime path authority.
|
||||
codewhale_paths::codewhale_home_override()
|
||||
.ok()
|
||||
.flatten()
|
||||
.or_else(codewhale_paths::legacy_deepseek_home_override)
|
||||
.or_else(codewhale_paths::legacy_deepseek_home)
|
||||
.unwrap_or_else(|| PathBuf::from(codewhale_paths::LEGACY_APP_DIR))
|
||||
|
||||
+5
-208
@@ -4426,7 +4426,9 @@ pub use codewhale_paths::{CODEWHALE_APP_DIR, LEGACY_APP_DIR};
|
||||
/// `$CODEWHALE_HOME` takes precedence when set. Otherwise defaults to
|
||||
/// `$HOME/.codewhale`. This is the write target for new product state.
|
||||
pub fn codewhale_home() -> Result<PathBuf> {
|
||||
codewhale_paths::codewhale_home().context("failed to resolve home directory")
|
||||
codewhale_paths::codewhale_home()
|
||||
.map_err(anyhow::Error::new)?
|
||||
.context("failed to resolve home directory")
|
||||
}
|
||||
|
||||
/// Whether `$CODEWHALE_HOME` is set to a non-empty value.
|
||||
@@ -4701,217 +4703,12 @@ pub fn resolve_config_path(explicit: Option<PathBuf>) -> Result<PathBuf> {
|
||||
if let Some(path) = explicit {
|
||||
return normalize_config_file_path(path);
|
||||
}
|
||||
if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
|
||||
if let Some(path) = config_path_from_env_value(&path)? {
|
||||
return Ok(path);
|
||||
}
|
||||
return default_config_path();
|
||||
}
|
||||
if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
|
||||
if let Some(path) = config_path_from_env_value(&path)? {
|
||||
return Ok(path);
|
||||
}
|
||||
return default_config_path();
|
||||
if let Some(path) = codewhale_paths::config_path_override().map_err(anyhow::Error::new)? {
|
||||
return normalize_config_file_path(path);
|
||||
}
|
||||
default_config_path()
|
||||
}
|
||||
|
||||
fn config_path_from_env_value(path: &str) -> Result<Option<PathBuf>> {
|
||||
let trimmed = path.trim();
|
||||
if trimmed.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
normalize_config_file_path(PathBuf::from(trimmed)).map(Some)
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `path` names a workspace-scoped config document —
|
||||
/// `<repo>/.codewhale/config.toml` (or the legacy `.deepseek` layout) inside a
|
||||
/// checkout — rather than a user-global config file.
|
||||
///
|
||||
/// Credential writes (api_key values, `auth_mode` markers, oauth/external
|
||||
/// credential pointers) must never target such a document: a key saved while
|
||||
/// working in one repo would be invisible from every other repo, and the repo
|
||||
/// file stores it in plaintext where it is easy to commit by accident (#5045).
|
||||
///
|
||||
/// A path is classified workspace-scoped only when its parent directory is a
|
||||
/// `.codewhale`/`.deepseek` app dir outside the user's home AND the document
|
||||
/// belongs to a workspace: it is relative (resolves against the process cwd),
|
||||
/// its base directory contains the process cwd, or its base directory is a
|
||||
/// checkout (has a `.git` entry). An explicit `$CODEWHALE_HOME` config is
|
||||
/// user-global wherever that home points, even when the directory itself
|
||||
/// happens to be named `.codewhale`; other custom locations (for example
|
||||
/// `CODEWHALE_CONFIG_PATH=~/team.toml` or an isolated test directory) stay
|
||||
/// honored as deliberate user-scoped choices.
|
||||
#[must_use]
|
||||
pub fn config_path_is_workspace_scoped(path: &Path) -> bool {
|
||||
config_path_is_workspace_scoped_with_context(
|
||||
path,
|
||||
codewhale_paths::codewhale_home_override().as_deref(),
|
||||
codewhale_paths::user_home().as_deref(),
|
||||
std::env::current_dir().ok().as_deref(),
|
||||
)
|
||||
}
|
||||
|
||||
/// Environment-free core of [`config_path_is_workspace_scoped`], split out so
|
||||
/// scope classification is testable without mutating process-global state.
|
||||
fn config_path_is_workspace_scoped_with_context(
|
||||
path: &Path,
|
||||
explicit_codewhale_home: Option<&Path>,
|
||||
user_home: Option<&Path>,
|
||||
current_dir: Option<&Path>,
|
||||
) -> bool {
|
||||
if let Some(home) = explicit_codewhale_home
|
||||
&& same_lexical_or_canonical_path(path, &home.join(CONFIG_FILE_NAME))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
let Some(parent) = path.parent() else {
|
||||
return false;
|
||||
};
|
||||
let parent_is_app_dir = parent
|
||||
.file_name()
|
||||
.and_then(OsStr::to_str)
|
||||
.is_some_and(|name| name == CODEWHALE_APP_DIR || name == LEGACY_APP_DIR);
|
||||
if !parent_is_app_dir {
|
||||
return false;
|
||||
}
|
||||
let Some(base) = parent.parent() else {
|
||||
return true;
|
||||
};
|
||||
if let Some(home) = user_home
|
||||
&& same_lexical_or_canonical_path(base, home)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if path.is_relative() {
|
||||
// Resolves against the process cwd: repo-scoped by construction.
|
||||
return true;
|
||||
}
|
||||
// The document belongs to the workspace the process is sitting in…
|
||||
if let Some(cwd) = current_dir
|
||||
&& canonicalize_or_keep(cwd).starts_with(canonicalize_or_keep(base))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// …or to some other checkout (a `.git` entry beside the app dir).
|
||||
base.join(".git").exists()
|
||||
}
|
||||
|
||||
/// Lexical equality first, canonical equality as a fallback so an existing
|
||||
/// path still matches through symlinked parents (e.g. `/tmp` on macOS).
|
||||
fn same_lexical_or_canonical_path(a: &Path, b: &Path) -> bool {
|
||||
a == b || canonicalize_or_keep(a) == canonicalize_or_keep(b)
|
||||
}
|
||||
|
||||
fn canonicalize_or_keep(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod credential_scope_tests {
|
||||
use super::config_path_is_workspace_scoped_with_context;
|
||||
use std::path::Path;
|
||||
|
||||
#[test]
|
||||
fn config_inside_current_workspace_is_workspace_scoped() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let repo = temp.path().join("repo");
|
||||
let cwd = repo.join("nested/dir");
|
||||
for app_dir in [".codewhale", ".deepseek"] {
|
||||
let config = repo.join(app_dir).join("config.toml");
|
||||
assert!(
|
||||
config_path_is_workspace_scoped_with_context(
|
||||
&config,
|
||||
None,
|
||||
Some(Path::new("/home/user")),
|
||||
Some(&cwd),
|
||||
),
|
||||
"{} should be workspace-scoped when cwd sits inside the repo",
|
||||
config.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_app_dir_config_is_workspace_scoped() {
|
||||
assert!(config_path_is_workspace_scoped_with_context(
|
||||
Path::new(".codewhale/config.toml"),
|
||||
None,
|
||||
Some(Path::new("/home/user")),
|
||||
Some(Path::new("/somewhere/else")),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_config_outside_cwd_is_workspace_scoped_via_git_marker() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let repo = temp.path().join("repo");
|
||||
std::fs::create_dir_all(repo.join(".git")).expect("git marker");
|
||||
std::fs::create_dir_all(repo.join(".codewhale")).expect("app dir");
|
||||
assert!(config_path_is_workspace_scoped_with_context(
|
||||
&repo.join(".codewhale/config.toml"),
|
||||
None,
|
||||
Some(Path::new("/home/user")),
|
||||
Some(Path::new("/somewhere/else")),
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_global_and_custom_locations_are_not_workspace_scoped() {
|
||||
let home = Path::new("/home/user");
|
||||
let elsewhere = Some(Path::new("/somewhere/else"));
|
||||
for global_config in [
|
||||
"/home/user/.codewhale/config.toml",
|
||||
"/home/user/.deepseek/config.toml",
|
||||
"/home/user/team-config.toml",
|
||||
"/etc/codewhale/config.toml",
|
||||
] {
|
||||
assert!(
|
||||
!config_path_is_workspace_scoped_with_context(
|
||||
Path::new(global_config),
|
||||
None,
|
||||
Some(home),
|
||||
elsewhere,
|
||||
),
|
||||
"{global_config} should stay user-global"
|
||||
);
|
||||
}
|
||||
// An isolated app-dir-shaped location with no workspace relationship
|
||||
// (no cwd ancestry, no checkout marker) stays honored: test harnesses
|
||||
// and deliberate overrides point there.
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
assert!(!config_path_is_workspace_scoped_with_context(
|
||||
&temp.path().join(".codewhale/config.toml"),
|
||||
None,
|
||||
Some(home),
|
||||
elsewhere,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn explicit_codewhale_home_config_is_user_global_even_when_dir_is_app_named() {
|
||||
let temp = tempfile::tempdir().expect("tempdir");
|
||||
let repo = temp.path().join("repo");
|
||||
let explicit = repo.join(".codewhale");
|
||||
// Even with cwd inside the repo, the explicit CODEWHALE_HOME config is
|
||||
// the user-global scope by definition.
|
||||
assert!(!config_path_is_workspace_scoped_with_context(
|
||||
&explicit.join("config.toml"),
|
||||
Some(&explicit),
|
||||
Some(Path::new("/home/user")),
|
||||
Some(&repo),
|
||||
));
|
||||
// A different repo-scoped document is still workspace-scoped.
|
||||
assert!(config_path_is_workspace_scoped_with_context(
|
||||
&repo.join("other/.codewhale/config.toml"),
|
||||
Some(&explicit),
|
||||
Some(Path::new("/home/user")),
|
||||
Some(&repo.join("other")),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn permissions_path_for_config_path(config_path: &Path) -> PathBuf {
|
||||
config_sibling_path_unchecked(config_path, OsStr::new(PERMISSIONS_FILE_NAME))
|
||||
|
||||
@@ -2709,6 +2709,25 @@ fn app_homes_prefer_home_env_before_platform_home_fallback() {
|
||||
assert_eq!(codewhale_home().expect("explicit home"), explicit);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_codewhale_home_is_a_hard_error() {
|
||||
let _lock = env_lock();
|
||||
let _env = StateEnvRestore {
|
||||
home: env::var_os("HOME"),
|
||||
userprofile: env::var_os("USERPROFILE"),
|
||||
codewhale_home: env::var_os("CODEWHALE_HOME"),
|
||||
};
|
||||
// Safety: test-only environment mutation is serialized by env_lock().
|
||||
unsafe {
|
||||
env::set_var("CODEWHALE_HOME", ".codewhale");
|
||||
}
|
||||
|
||||
let error = codewhale_home().expect_err("relative global home must fail closed");
|
||||
let message = format!("{error:#}");
|
||||
assert!(message.contains("CODEWHALE_HOME"), "{message}");
|
||||
assert!(message.contains("absolute"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn migrate_config_reports_copied_legacy_path() {
|
||||
let _lock = env_lock();
|
||||
@@ -3175,7 +3194,7 @@ fn config_store_save_revalidates_path_before_parent_creation() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_config_path_rejects_env_traversal() {
|
||||
fn resolve_config_path_rejects_relative_env_path_before_cwd_resolution() {
|
||||
let _lock = env_lock();
|
||||
struct ConfigPathEnvGuard {
|
||||
codewhale: Option<OsString>,
|
||||
@@ -3207,8 +3226,10 @@ fn resolve_config_path_rejects_env_traversal() {
|
||||
env::remove_var("DEEPSEEK_CONFIG_PATH");
|
||||
}
|
||||
|
||||
let err = resolve_config_path(None).expect_err("env traversal should fail");
|
||||
assert!(format!("{err:#}").contains("cannot contain '..'"));
|
||||
let err = resolve_config_path(None).expect_err("relative env path should fail");
|
||||
let message = format!("{err:#}");
|
||||
assert!(message.contains("CODEWHALE_CONFIG_PATH"), "{message}");
|
||||
assert!(message.contains("absolute"), "{message}");
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
|
||||
+135
-10
@@ -6,6 +6,7 @@
|
||||
#![deny(missing_docs)]
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::fmt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Canonical Codewhale app directory name under the user home.
|
||||
@@ -14,21 +15,56 @@ pub const CODEWHALE_APP_DIR: &str = ".codewhale";
|
||||
/// Legacy DeepSeek-branded directory retained for compatibility reads.
|
||||
pub const LEGACY_APP_DIR: &str = ".deepseek";
|
||||
|
||||
/// An environment-provided runtime path was not safe to use as a global path.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PathOverrideError {
|
||||
variable: &'static str,
|
||||
path: PathBuf,
|
||||
kind: PathOverrideErrorKind,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum PathOverrideErrorKind {
|
||||
Relative,
|
||||
HomeUnavailable,
|
||||
}
|
||||
|
||||
impl fmt::Display for PathOverrideError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self.kind {
|
||||
PathOverrideErrorKind::Relative => write!(
|
||||
formatter,
|
||||
"{} must be an absolute path, got {}",
|
||||
self.variable,
|
||||
self.path.display()
|
||||
),
|
||||
PathOverrideErrorKind::HomeUnavailable => write!(
|
||||
formatter,
|
||||
"{} uses '~', but the user home directory could not be resolved: {}",
|
||||
self.variable,
|
||||
self.path.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for PathOverrideError {}
|
||||
|
||||
/// Return the explicit Codewhale home override, if one is configured.
|
||||
///
|
||||
/// Unicode values are trimmed so whitespace-only values are treated as unset,
|
||||
/// matching the existing config and secret-store contract. Non-Unicode path
|
||||
/// values are preserved on platforms that support them instead of silently
|
||||
/// dropping an otherwise valid filesystem path.
|
||||
#[must_use]
|
||||
pub fn codewhale_home_override() -> Option<PathBuf> {
|
||||
path_env("CODEWHALE_HOME")
|
||||
/// dropping an otherwise valid filesystem path. A leading `~` is expanded;
|
||||
/// every other relative value is rejected.
|
||||
pub fn codewhale_home_override() -> Result<Option<PathBuf>, PathOverrideError> {
|
||||
absolute_path_env("CODEWHALE_HOME")
|
||||
}
|
||||
|
||||
/// Whether `CODEWHALE_HOME` establishes an explicit isolation boundary.
|
||||
#[must_use]
|
||||
pub fn codewhale_home_is_explicit() -> bool {
|
||||
codewhale_home_override().is_some()
|
||||
path_env("CODEWHALE_HOME").is_some()
|
||||
}
|
||||
|
||||
/// Return the legacy `DEEPSEEK_HOME` compatibility override, if configured.
|
||||
@@ -69,11 +105,74 @@ fn windows_home_from_environment() -> Option<PathBuf> {
|
||||
|
||||
/// Resolve the canonical Codewhale runtime home.
|
||||
///
|
||||
/// An explicit `CODEWHALE_HOME` is returned verbatim. Otherwise this is
|
||||
/// `<user home>/.codewhale`.
|
||||
#[must_use]
|
||||
pub fn codewhale_home() -> Option<PathBuf> {
|
||||
codewhale_home_override().or_else(|| user_home().map(|home| home.join(CODEWHALE_APP_DIR)))
|
||||
/// A valid explicit `CODEWHALE_HOME` is returned after `~` expansion. Otherwise
|
||||
/// this is `<user home>/.codewhale`.
|
||||
pub fn codewhale_home() -> Result<Option<PathBuf>, PathOverrideError> {
|
||||
Ok(codewhale_home_override()?.or_else(|| user_home().map(|home| home.join(CODEWHALE_APP_DIR))))
|
||||
}
|
||||
|
||||
/// Return the explicit config-file override, preferring the Codewhale name.
|
||||
///
|
||||
/// `~` is expanded through the canonical user-home resolver before the path is
|
||||
/// validated. All other relative paths are rejected so a process working in a
|
||||
/// repository can never turn a global config override into a repo-local file.
|
||||
pub fn config_path_override() -> Result<Option<PathBuf>, PathOverrideError> {
|
||||
if let Some(path) = absolute_path_env("CODEWHALE_CONFIG_PATH")? {
|
||||
return Ok(Some(path));
|
||||
}
|
||||
absolute_path_env("DEEPSEEK_CONFIG_PATH")
|
||||
}
|
||||
|
||||
/// Read an optional path environment variable and require a global path.
|
||||
///
|
||||
/// Empty and whitespace-only values are treated as unset. A leading `~` path
|
||||
/// is expanded first; any path still relative after expansion is rejected.
|
||||
pub fn absolute_path_env(variable: &'static str) -> Result<Option<PathBuf>, PathOverrideError> {
|
||||
path_env(variable)
|
||||
.map(|path| validate_absolute_path(variable, path))
|
||||
.transpose()
|
||||
}
|
||||
|
||||
/// Expand a leading `~` and reject a path that is not absolute.
|
||||
pub fn validate_absolute_path(
|
||||
variable: &'static str,
|
||||
path: PathBuf,
|
||||
) -> Result<PathBuf, PathOverrideError> {
|
||||
let original = path.clone();
|
||||
let path = match path.to_str() {
|
||||
Some("~") => user_home().ok_or_else(|| PathOverrideError {
|
||||
variable,
|
||||
path: original.clone(),
|
||||
kind: PathOverrideErrorKind::HomeUnavailable,
|
||||
})?,
|
||||
Some(value)
|
||||
if value
|
||||
.strip_prefix('~')
|
||||
.is_some_and(|suffix| suffix.starts_with('/') || suffix.starts_with('\\')) =>
|
||||
{
|
||||
let mut home = user_home().ok_or_else(|| PathOverrideError {
|
||||
variable,
|
||||
path: original.clone(),
|
||||
kind: PathOverrideErrorKind::HomeUnavailable,
|
||||
})?;
|
||||
let suffix = value[1..].trim_start_matches(['/', '\\']);
|
||||
if !suffix.is_empty() {
|
||||
home.push(suffix);
|
||||
}
|
||||
home
|
||||
}
|
||||
_ => path,
|
||||
};
|
||||
|
||||
if path.is_absolute() {
|
||||
Ok(path)
|
||||
} else {
|
||||
Err(PathOverrideError {
|
||||
variable,
|
||||
path: original,
|
||||
kind: PathOverrideErrorKind::Relative,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the ambient legacy DeepSeek home used for compatibility reads.
|
||||
@@ -116,6 +215,32 @@ mod tests {
|
||||
assert_eq!(normalize_path_value(OsString::new()), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_global_overrides_are_rejected_with_the_variable_name() {
|
||||
let error = validate_absolute_path(
|
||||
"CODEWHALE_CONFIG_PATH",
|
||||
PathBuf::from(".codewhale/config.toml"),
|
||||
)
|
||||
.expect_err("relative global config path must fail closed");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("CODEWHALE_CONFIG_PATH"), "{message}");
|
||||
assert!(message.contains(".codewhale/config.toml"), "{message}");
|
||||
assert!(message.contains("absolute"), "{message}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn absolute_global_overrides_are_preserved() {
|
||||
let path = if cfg!(windows) {
|
||||
PathBuf::from(r"C:\codewhale\config.toml")
|
||||
} else {
|
||||
PathBuf::from("/tmp/codewhale/config.toml")
|
||||
};
|
||||
assert_eq!(
|
||||
validate_absolute_path("CODEWHALE_CONFIG_PATH", path.clone()),
|
||||
Ok(path)
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn unix_non_unicode_path_values_are_preserved() {
|
||||
|
||||
@@ -658,6 +658,9 @@ impl KeyringStore for FileKeyringStore {
|
||||
|
||||
fn default_codewhale_secrets_path() -> Result<PathBuf, SecretsError> {
|
||||
Ok(codewhale_paths::codewhale_home()
|
||||
.map_err(|error| {
|
||||
SecretsError::Io(std::io::Error::new(std::io::ErrorKind::InvalidInput, error))
|
||||
})?
|
||||
.ok_or_else(home_resolution_error)?
|
||||
.join("secrets")
|
||||
.join("secrets.json"))
|
||||
|
||||
@@ -1819,7 +1819,7 @@ pub fn default_state_db_path() -> PathBuf {
|
||||
// defeat the isolation the override promises (CI, containers, multi-project,
|
||||
// test harnesses). Legacy ~/.deepseek migration only applies to the default
|
||||
// home location.
|
||||
if let Some(overridden) = codewhale_home_override() {
|
||||
if let Some(overridden) = codewhale_home_override().ok().flatten() {
|
||||
return overridden.join("state.db");
|
||||
}
|
||||
let home = codewhale_paths::user_home().unwrap_or_else(|| PathBuf::from("."));
|
||||
@@ -2457,12 +2457,13 @@ mod tests {
|
||||
#[test]
|
||||
fn codewhale_home_override_returns_the_env_value_verbatim() {
|
||||
let _lock = CODEWHALE_HOME_TEST_LOCK.lock().unwrap();
|
||||
let _g = CodeWhaleHomeGuard::set("/tmp/cw-isolated-state");
|
||||
let override_path = std::env::temp_dir().join("cw-isolated-state");
|
||||
let _g = CodeWhaleHomeGuard::set(override_path.to_str().unwrap());
|
||||
// The env var IS the home dir — no ".codewhale" appended. This matches
|
||||
// codewhale_home() in config ($CODEWHALE_HOME=/x means home is /x).
|
||||
assert_eq!(
|
||||
codewhale_home_override().as_deref(),
|
||||
Some(std::path::Path::new("/tmp/cw-isolated-state"))
|
||||
codewhale_home_override().unwrap().as_deref(),
|
||||
Some(override_path.as_path())
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2470,7 +2471,7 @@ mod tests {
|
||||
fn codewhale_home_override_none_when_unset() {
|
||||
let _lock = CODEWHALE_HOME_TEST_LOCK.lock().unwrap();
|
||||
let _g = CodeWhaleHomeGuard::remove();
|
||||
assert!(codewhale_home_override().is_none());
|
||||
assert!(codewhale_home_override().unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -2478,7 +2479,7 @@ mod tests {
|
||||
let _lock = CODEWHALE_HOME_TEST_LOCK.lock().unwrap();
|
||||
let _g = CodeWhaleHomeGuard::set(" ");
|
||||
assert!(
|
||||
codewhale_home_override().is_none(),
|
||||
codewhale_home_override().unwrap().is_none(),
|
||||
"whitespace-only CODEWHALE_HOME must not establish isolation"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1167,7 +1167,7 @@ pub fn default_automations_dir() -> PathBuf {
|
||||
// NOT fall back to the legacy ~/.deepseek path — silent fallback would
|
||||
// defeat the isolation the override promises. Check the env var directly
|
||||
// (not codewhale_home()'s Ok/Err, which succeeds for the default home too).
|
||||
if let Some(home) = codewhale_paths::codewhale_home_override() {
|
||||
if let Some(home) = codewhale_paths::codewhale_home_override().ok().flatten() {
|
||||
return home.join("automations");
|
||||
}
|
||||
codewhale_paths::user_home()
|
||||
|
||||
+117
-121
@@ -3469,8 +3469,10 @@ impl Config {
|
||||
}
|
||||
|
||||
if let Some(profile) = profile {
|
||||
let Some(path) = resolve_load_config_path(config_path.map(Path::to_path_buf)) else {
|
||||
return Some("an unresolved active config profile");
|
||||
let path = match resolve_load_config_path(config_path.map(Path::to_path_buf)) {
|
||||
Ok(Some(path)) => path,
|
||||
Ok(None) => return Some("an unresolved active config profile"),
|
||||
Err(_) => return Some("an invalid active config path override"),
|
||||
};
|
||||
let Some(parsed) = std::fs::read_to_string(path)
|
||||
.ok()
|
||||
@@ -3556,12 +3558,15 @@ impl Config {
|
||||
return ApprovalPolicyControl::Environment;
|
||||
}
|
||||
|
||||
let Some(path) = resolve_load_config_path(config_path.map(Path::to_path_buf)) else {
|
||||
return if self.approval_policy.is_some() {
|
||||
ApprovalPolicyControl::Ambiguous
|
||||
} else {
|
||||
ApprovalPolicyControl::Unset
|
||||
};
|
||||
let path = match resolve_load_config_path(config_path.map(Path::to_path_buf)) {
|
||||
Ok(Some(path)) => path,
|
||||
Ok(None) | Err(_) => {
|
||||
return if self.approval_policy.is_some() {
|
||||
ApprovalPolicyControl::Ambiguous
|
||||
} else {
|
||||
ApprovalPolicyControl::Unset
|
||||
};
|
||||
}
|
||||
};
|
||||
let parsed = std::fs::read_to_string(path)
|
||||
.ok()
|
||||
@@ -3633,12 +3638,15 @@ impl Config {
|
||||
return ShellAccessControl::Environment;
|
||||
}
|
||||
|
||||
let Some(path) = resolve_load_config_path(config_path.map(Path::to_path_buf)) else {
|
||||
return if self.allow_shell.is_some() {
|
||||
ShellAccessControl::Ambiguous
|
||||
} else {
|
||||
ShellAccessControl::Unset
|
||||
};
|
||||
let path = match resolve_load_config_path(config_path.map(Path::to_path_buf)) {
|
||||
Ok(Some(path)) => path,
|
||||
Ok(None) | Err(_) => {
|
||||
return if self.allow_shell.is_some() {
|
||||
ShellAccessControl::Ambiguous
|
||||
} else {
|
||||
ShellAccessControl::Unset
|
||||
};
|
||||
}
|
||||
};
|
||||
let parsed = std::fs::read_to_string(path)
|
||||
.ok()
|
||||
@@ -3814,7 +3822,7 @@ impl Config {
|
||||
profile: Option<&str>,
|
||||
environment_policy: ConfigEnvironmentPolicy,
|
||||
) -> Result<Self> {
|
||||
let path = resolve_load_config_path(path);
|
||||
let path = resolve_load_config_path(path)?;
|
||||
let mut config = if let Some(path) = path.as_ref() {
|
||||
if path.exists() {
|
||||
let contents = fs::read_to_string(path)
|
||||
@@ -6368,17 +6376,34 @@ mod paths;
|
||||
use paths::{
|
||||
canonicalize_or_keep, codewhale_home_dir, default_config_path, default_managed_config_path,
|
||||
default_mcp_config_path, default_memory_path, default_notes_path, default_requirements_path,
|
||||
default_skills_dir, env_config_path, expand_pathbuf, home_config_path, workspace_config_key,
|
||||
default_skills_dir, env_config_path, expand_pathbuf, try_default_config_path,
|
||||
workspace_config_key,
|
||||
};
|
||||
pub(crate) use paths::{effective_home_dir, expand_path};
|
||||
|
||||
pub(crate) fn workspace_trust_config_candidate_paths() -> Vec<PathBuf> {
|
||||
if let Some(path) = env_config_path() {
|
||||
return vec![path];
|
||||
match env_config_path() {
|
||||
Ok(Some(path)) => return vec![path],
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
error = %error,
|
||||
"invalid config path override; refusing workspace-trust fallback"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(codewhale_home) = codewhale_home_dir() {
|
||||
return vec![codewhale_home.join("config.toml")];
|
||||
match codewhale_home_dir() {
|
||||
Ok(Some(codewhale_home)) => return vec![codewhale_home.join("config.toml")],
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
error = %error,
|
||||
"invalid Codewhale home override; refusing workspace-trust fallback"
|
||||
);
|
||||
return Vec::new();
|
||||
}
|
||||
}
|
||||
|
||||
let Some(home) = effective_home_dir() else {
|
||||
@@ -6392,8 +6417,15 @@ pub(crate) fn workspace_trust_config_candidate_paths() -> Vec<PathBuf> {
|
||||
|
||||
#[must_use]
|
||||
pub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {
|
||||
let Some(config_path) = default_config_path() else {
|
||||
return false;
|
||||
let config_path = match default_config_path() {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
error = %error,
|
||||
"failed to resolve workspace-trust config; treating workspace as untrusted"
|
||||
);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
let Ok(raw) = fs::read_to_string(config_path) else {
|
||||
return false;
|
||||
@@ -6405,8 +6437,8 @@ pub(crate) fn is_workspace_trusted(workspace: &Path) -> bool {
|
||||
}
|
||||
|
||||
pub(crate) fn save_workspace_trust(workspace: &Path) -> Result<PathBuf> {
|
||||
let config_path = default_config_path()
|
||||
.context("Failed to resolve config path: home directory not found.")?;
|
||||
let config_path =
|
||||
try_default_config_path().context("Failed to resolve config path for workspace trust.")?;
|
||||
ensure_parent_dir(&config_path)?;
|
||||
|
||||
let project_key = workspace_config_key(workspace);
|
||||
@@ -6446,9 +6478,9 @@ fn is_trusted_level(level: &str) -> bool {
|
||||
level.trim().eq_ignore_ascii_case("trusted")
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_load_config_path(path: Option<PathBuf>) -> Option<PathBuf> {
|
||||
pub(crate) fn resolve_load_config_path(path: Option<PathBuf>) -> Result<Option<PathBuf>> {
|
||||
if let Some(path) = path {
|
||||
return Some(expand_pathbuf(path));
|
||||
return Ok(Some(expand_pathbuf(path)));
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -6456,36 +6488,18 @@ pub(crate) fn resolve_load_config_path(path: Option<PathBuf>) -> Option<PathBuf>
|
||||
let honor_guarded_environment = crate::test_support::current_thread_holds_test_env_lock();
|
||||
crate::test_support::with_test_env_lock(|| {
|
||||
if honor_guarded_environment {
|
||||
resolve_default_load_config_path()
|
||||
try_default_config_path().map(Some)
|
||||
} else {
|
||||
Some(
|
||||
Ok(Some(
|
||||
crate::test_support::isolated_test_state_root()
|
||||
.join(codewhale_config::CONFIG_FILE_NAME),
|
||||
)
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
resolve_default_load_config_path()
|
||||
}
|
||||
|
||||
fn resolve_default_load_config_path() -> Option<PathBuf> {
|
||||
if let Some(path) = env_config_path() {
|
||||
if path.exists() {
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
if let Some(home_path) = home_config_path()
|
||||
&& home_path.exists()
|
||||
{
|
||||
return Some(home_path);
|
||||
}
|
||||
|
||||
return Some(path);
|
||||
}
|
||||
|
||||
home_config_path()
|
||||
try_default_config_path().map(Some)
|
||||
}
|
||||
|
||||
/// Create an inspectable config file on first interactive launch.
|
||||
@@ -6493,10 +6507,10 @@ fn resolve_default_load_config_path() -> Option<PathBuf> {
|
||||
/// The file intentionally omits `api_key`; onboarding or `codewhale auth set`
|
||||
/// writes that field after the user supplies a key.
|
||||
pub fn ensure_config_file_exists(path: Option<PathBuf>) -> Result<Option<PathBuf>> {
|
||||
let config_path = path
|
||||
.map(expand_pathbuf)
|
||||
.or_else(default_config_path)
|
||||
.context("Failed to resolve config path: home directory not found.")?;
|
||||
let config_path = match path {
|
||||
Some(path) => expand_pathbuf(path),
|
||||
None => default_config_path().context("Failed to resolve config path.")?,
|
||||
};
|
||||
if config_path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
@@ -9047,9 +9061,10 @@ pub enum SavedCredential {
|
||||
/// Absolute path to the credential-free config metadata file.
|
||||
path: PathBuf,
|
||||
},
|
||||
/// Stored in the Codewhale config file only. Fallback when the selected
|
||||
/// secret backend cannot be written, or under `cfg(test)` so unit tests do
|
||||
/// not pollute the host credential store.
|
||||
/// Stored in the Codewhale config file only under `cfg(test)` so unit tests
|
||||
/// without an explicitly isolated secret backend do not pollute the host
|
||||
/// credential store. Production save flows never automatically downgrade
|
||||
/// a failed secret-store write to plaintext.
|
||||
ConfigFile(PathBuf),
|
||||
}
|
||||
|
||||
@@ -9070,40 +9085,13 @@ impl SavedCredential {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the config document for CREDENTIAL writes: api_key values,
|
||||
/// `auth_mode` markers, and oauth/external-credential pointers.
|
||||
///
|
||||
/// Credentials are user-global — a key saved while working in one repo must be
|
||||
/// visible from every other repo (#5045). The ambient
|
||||
/// `CODEWHALE_CONFIG_PATH`/`DEEPSEEK_CONFIG_PATH` override can point at a
|
||||
/// workspace-scoped document (`<repo>/.codewhale/config.toml`, plaintext and
|
||||
/// easy to commit by accident), so credential writes that would land there are
|
||||
/// rescoped to the user-global config instead. Non-credential settings keep
|
||||
/// the ambient scoping, and callers that pass an explicit config path never
|
||||
/// consult this resolver.
|
||||
fn credential_config_path() -> Option<PathBuf> {
|
||||
let resolved = default_config_path()?;
|
||||
if !codewhale_config::config_path_is_workspace_scoped(&resolved) {
|
||||
return Some(resolved);
|
||||
}
|
||||
let global = home_config_path();
|
||||
if let Some(global) = global.as_ref() {
|
||||
tracing::info!(
|
||||
ambient = %resolved.display(),
|
||||
global = %global.display(),
|
||||
"rescoping credential write from workspace config to user-global config"
|
||||
);
|
||||
}
|
||||
global
|
||||
}
|
||||
|
||||
/// Save the active provider's API key.
|
||||
///
|
||||
/// The selected durable secret backend is attempted first. On success the
|
||||
/// config keeps only non-secret auth metadata and any older plaintext copy is
|
||||
/// removed. When the secret-store write fails (OS permission denied, corrupt
|
||||
/// or read-only file backend, etc.), `config.toml` remains the headless-safe
|
||||
/// fallback and the function reports [`SavedCredential::ConfigFile`].
|
||||
/// or read-only file backend, etc.), the save fails loudly rather than writing
|
||||
/// the key to plaintext `config.toml`.
|
||||
///
|
||||
/// Under `cfg(test)` the secret-store path is enabled only when the test sets
|
||||
/// both an isolated `CODEWHALE_HOME` and an explicit backend, preventing unit
|
||||
@@ -9122,8 +9110,7 @@ fn save_root_api_key_for_secret_slot(
|
||||
anyhow::bail!("Refusing to save an empty API key.");
|
||||
}
|
||||
|
||||
let path = credential_config_path()
|
||||
.context("Failed to resolve config path: home directory not found.")?;
|
||||
let path = try_default_config_path().context("Failed to resolve config path for API key.")?;
|
||||
|
||||
if let Some(secrets) = credential_secret_store_for_save() {
|
||||
let prior_secret = secrets.get(secret_slot);
|
||||
@@ -9165,16 +9152,13 @@ fn save_root_api_key_for_secret_slot(
|
||||
return Ok(SavedCredential::KeyringAndConfigFile { backend, path });
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
"secret-store write failed; key saved to config.toml only: {err}"
|
||||
);
|
||||
// Fall through to the ConfigFile-only outcome below.
|
||||
return Err(plaintext_credential_fallback_refused("write", &path, &err));
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"secret-store snapshot failed; key saved to config.toml only: {error}"
|
||||
);
|
||||
return Err(plaintext_credential_fallback_refused(
|
||||
"snapshot", &path, &error,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9184,6 +9168,17 @@ fn save_root_api_key_for_secret_slot(
|
||||
Ok(SavedCredential::ConfigFile(path))
|
||||
}
|
||||
|
||||
fn plaintext_credential_fallback_refused(
|
||||
operation: &str,
|
||||
config_path: &Path,
|
||||
failure: &dyn std::fmt::Display,
|
||||
) -> anyhow::Error {
|
||||
anyhow::anyhow!(
|
||||
"Secret storage {operation} failed: {failure}. Refusing to write the API key in plaintext to {}. Fix the configured secret backend and retry; Codewhale did not change that file.",
|
||||
codewhale_config::quote_os_path(config_path)
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
fn credential_secret_store_for_save() -> Option<codewhale_secrets::Secrets> {
|
||||
Some(codewhale_secrets::Secrets::auto_detect())
|
||||
@@ -9233,8 +9228,8 @@ fn save_root_api_key_metadata_without_plaintext(
|
||||
|
||||
/// Write the `api_key` slot directly to `config.toml`.
|
||||
fn save_api_key_to_config_file(api_key: &str) -> Result<PathBuf> {
|
||||
let config_path = credential_config_path()
|
||||
.context("Failed to resolve config path: home directory not found.")?;
|
||||
let config_path =
|
||||
try_default_config_path().context("Failed to resolve config path for API key.")?;
|
||||
|
||||
ensure_parent_dir(&config_path)?;
|
||||
|
||||
@@ -9700,8 +9695,8 @@ fn save_api_key_for_identity_unlocked(
|
||||
let api_key = api_key.trim();
|
||||
anyhow::ensure!(!api_key.is_empty(), "Refusing to save an empty API key.");
|
||||
|
||||
let config_path = credential_config_path()
|
||||
.context("Failed to resolve config path: home directory not found.")?;
|
||||
let config_path =
|
||||
try_default_config_path().context("Failed to resolve config path for provider API key.")?;
|
||||
ensure_parent_dir(&config_path)?;
|
||||
|
||||
let key_inside = if provider == ApiProvider::Custom {
|
||||
@@ -9800,17 +9795,19 @@ fn save_api_key_for_identity_unlocked(
|
||||
return Ok(config_path);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
provider = %identity.key,
|
||||
"secret-store write failed; key saved to config.toml only: {err}"
|
||||
);
|
||||
return Err(plaintext_credential_fallback_refused(
|
||||
"write",
|
||||
&config_path,
|
||||
&err,
|
||||
));
|
||||
}
|
||||
},
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
provider = %identity.key,
|
||||
"secret-store snapshot failed; key saved to config.toml only: {error}"
|
||||
);
|
||||
return Err(plaintext_credential_fallback_refused(
|
||||
"snapshot",
|
||||
&config_path,
|
||||
&error,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9872,8 +9869,8 @@ pub(crate) fn save_provider_model_for_identity(
|
||||
let model = model.trim();
|
||||
anyhow::ensure!(!model.is_empty(), "model cannot be empty");
|
||||
|
||||
let config_path = default_config_path()
|
||||
.context("Failed to resolve config path: home directory not found.")?;
|
||||
let config_path =
|
||||
try_default_config_path().context("Failed to resolve config path for provider model.")?;
|
||||
ensure_parent_dir(&config_path)?;
|
||||
|
||||
let is_legacy_literal_custom = provider == ApiProvider::Custom
|
||||
@@ -9920,8 +9917,8 @@ pub(crate) fn save_provider_base_url_for_identity(
|
||||
) -> Result<PathBuf> {
|
||||
let base_url = base_url.trim();
|
||||
anyhow::ensure!(!base_url.is_empty(), "base URL cannot be empty");
|
||||
let config_path = default_config_path()
|
||||
.context("Failed to resolve config path: home directory not found.")?;
|
||||
let config_path = try_default_config_path()
|
||||
.context("Failed to resolve config path for provider base URL.")?;
|
||||
ensure_parent_dir(&config_path)?;
|
||||
let key_inside = if identity.provider == ApiProvider::Custom {
|
||||
let key = identity.key.trim();
|
||||
@@ -9949,8 +9946,8 @@ pub(crate) fn save_provider_context_window_for_identity(
|
||||
context_window: u32,
|
||||
) -> Result<PathBuf> {
|
||||
anyhow::ensure!(context_window > 0, "context window must be greater than 0");
|
||||
let config_path = default_config_path()
|
||||
.context("Failed to resolve config path: home directory not found.")?;
|
||||
let config_path = try_default_config_path()
|
||||
.context("Failed to resolve config path for provider context window.")?;
|
||||
ensure_parent_dir(&config_path)?;
|
||||
let key_inside = if identity.provider == ApiProvider::Custom {
|
||||
let key = identity.key.trim();
|
||||
@@ -10006,8 +10003,8 @@ pub(crate) fn persist_external_credential_consent_for_at(
|
||||
)?;
|
||||
let config_path = match config_path {
|
||||
Some(path) => path.to_path_buf(),
|
||||
None => credential_config_path()
|
||||
.context("Failed to resolve config path: home directory not found.")?,
|
||||
None => try_default_config_path()
|
||||
.context("Failed to resolve config path for external credential consent.")?,
|
||||
};
|
||||
ensure_parent_dir(&config_path)?;
|
||||
let key_inside = provider_config_key(provider).context("external credential provider key")?;
|
||||
@@ -10076,8 +10073,8 @@ pub(crate) fn revoke_external_credential_consent_for_at(
|
||||
);
|
||||
let config_path = match config_path {
|
||||
Some(path) => path.to_path_buf(),
|
||||
None => credential_config_path()
|
||||
.context("Failed to resolve config path: home directory not found.")?,
|
||||
None => try_default_config_path()
|
||||
.context("Failed to resolve config path for external credential consent.")?,
|
||||
};
|
||||
ensure_parent_dir(&config_path)?;
|
||||
let key_inside = provider_config_key(provider).context("external credential provider key")?;
|
||||
@@ -10250,10 +10247,9 @@ pub fn clear_api_key() -> Result<()> {
|
||||
fn clear_api_key_unlocked() -> Result<()> {
|
||||
// Strip api_key entries from config.toml, including provider-scoped
|
||||
// nested entries. Clearing a config file must not trigger platform
|
||||
// credential prompts. Clears target the same user-global document that
|
||||
// credential saves write, so logout removes what login stored (#5045).
|
||||
let config_path = credential_config_path()
|
||||
.context("Failed to resolve config path: home directory not found.")?;
|
||||
// credential prompts.
|
||||
let config_path = try_default_config_path()
|
||||
.context("Failed to resolve config path while clearing API keys.")?;
|
||||
|
||||
if !config_path.exists() {
|
||||
return Ok(());
|
||||
@@ -10299,8 +10295,8 @@ pub fn clear_active_provider_api_key(provider: &str) -> Result<()> {
|
||||
}
|
||||
|
||||
fn clear_active_provider_api_key_unlocked(provider: &str) -> Result<()> {
|
||||
let config_path = credential_config_path()
|
||||
.context("Failed to resolve config path: home directory not found.")?;
|
||||
let config_path = try_default_config_path()
|
||||
.context("Failed to resolve config path while clearing API keys.")?;
|
||||
|
||||
if !config_path.exists() {
|
||||
return Ok(());
|
||||
|
||||
@@ -18,52 +18,36 @@ use std::path::{Path, PathBuf};
|
||||
/// includable from an integration test binary — see that file's header.
|
||||
pub(crate) use super::home::effective_home_dir;
|
||||
|
||||
pub(crate) fn default_config_path() -> Option<PathBuf> {
|
||||
pub(crate) fn default_config_path() -> anyhow::Result<PathBuf> {
|
||||
try_default_config_path()
|
||||
}
|
||||
|
||||
pub(crate) fn try_default_config_path() -> anyhow::Result<PathBuf> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let honor_guarded_environment = crate::test_support::current_thread_holds_test_env_lock();
|
||||
crate::test_support::with_test_env_lock(|| {
|
||||
if honor_guarded_environment {
|
||||
default_config_path_from_environment()
|
||||
try_default_config_path_from_environment()
|
||||
} else {
|
||||
Some(
|
||||
crate::test_support::isolated_test_state_root()
|
||||
.join(codewhale_config::CONFIG_FILE_NAME),
|
||||
)
|
||||
Ok(crate::test_support::isolated_test_state_root()
|
||||
.join(codewhale_config::CONFIG_FILE_NAME))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
default_config_path_from_environment()
|
||||
try_default_config_path_from_environment()
|
||||
}
|
||||
|
||||
fn default_config_path_from_environment() -> Option<PathBuf> {
|
||||
env_config_path_unlocked().or_else(home_config_path)
|
||||
fn try_default_config_path_from_environment() -> anyhow::Result<PathBuf> {
|
||||
codewhale_config::resolve_config_path(None)
|
||||
}
|
||||
|
||||
pub(crate) fn codewhale_home_dir() -> Option<PathBuf> {
|
||||
pub(crate) fn codewhale_home_dir() -> Result<Option<PathBuf>, codewhale_paths::PathOverrideError> {
|
||||
codewhale_paths::codewhale_home_override()
|
||||
}
|
||||
|
||||
pub(crate) fn home_config_path() -> Option<PathBuf> {
|
||||
if let Some(home) = codewhale_home_dir() {
|
||||
return Some(home.join("config.toml"));
|
||||
}
|
||||
|
||||
effective_home_dir().map(|home| {
|
||||
let primary = home.join(".codewhale").join("config.toml");
|
||||
if primary.exists() {
|
||||
return primary;
|
||||
}
|
||||
let legacy = home.join(".deepseek").join("config.toml");
|
||||
if legacy.exists() {
|
||||
return legacy;
|
||||
}
|
||||
primary
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn workspace_config_key(workspace: &Path) -> String {
|
||||
canonicalize_or_keep(workspace)
|
||||
.to_string_lossy()
|
||||
@@ -74,7 +58,7 @@ pub(crate) fn canonicalize_or_keep(path: &Path) -> PathBuf {
|
||||
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
pub(crate) fn env_config_path() -> Option<PathBuf> {
|
||||
pub(crate) fn env_config_path() -> Result<Option<PathBuf>, codewhale_paths::PathOverrideError> {
|
||||
#[cfg(test)]
|
||||
{
|
||||
crate::test_support::with_test_env_lock(env_config_path_unlocked)
|
||||
@@ -85,20 +69,8 @@ pub(crate) fn env_config_path() -> Option<PathBuf> {
|
||||
}
|
||||
}
|
||||
|
||||
fn env_config_path_unlocked() -> Option<PathBuf> {
|
||||
if let Ok(path) = std::env::var("CODEWHALE_CONFIG_PATH") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(expand_path(trimmed));
|
||||
}
|
||||
}
|
||||
if let Ok(path) = std::env::var("DEEPSEEK_CONFIG_PATH") {
|
||||
let trimmed = path.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(expand_path(trimmed));
|
||||
}
|
||||
}
|
||||
None
|
||||
fn env_config_path_unlocked() -> Result<Option<PathBuf>, codewhale_paths::PathOverrideError> {
|
||||
codewhale_paths::config_path_override()
|
||||
}
|
||||
|
||||
pub(crate) fn expand_pathbuf(path: PathBuf) -> PathBuf {
|
||||
@@ -175,8 +147,16 @@ pub(crate) fn default_memory_path() -> Option<PathBuf> {
|
||||
}
|
||||
|
||||
fn default_user_state_path(name: &str) -> Option<PathBuf> {
|
||||
if let Some(home) = codewhale_home_dir() {
|
||||
return Some(home.join(name));
|
||||
match codewhale_home_dir() {
|
||||
Ok(Some(home)) => return Some(home.join(name)),
|
||||
Ok(None) => {}
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
error = %error,
|
||||
"invalid Codewhale home override; refusing to substitute a different state root"
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
effective_home_dir().map(|home| {
|
||||
let primary = home.join(".codewhale").join(name);
|
||||
|
||||
+157
-14
@@ -568,7 +568,7 @@ fn load_honors_codewhale_home_for_primary_config_path() -> Result<()> {
|
||||
let _deepseek_config = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
|
||||
|
||||
let expected = codewhale_home.join("config.toml");
|
||||
assert_eq!(default_config_path().as_deref(), Some(expected.as_path()));
|
||||
assert_eq!(default_config_path()?, expected);
|
||||
let config = Config::load(None, None)?;
|
||||
|
||||
assert_eq!(config.provider.as_deref(), Some("zai"));
|
||||
@@ -2790,6 +2790,7 @@ fn whitespace_codewhale_home_never_opens_ambient_file_secret_store() -> Result<(
|
||||
.join("secrets.json");
|
||||
let before = fs::read(&ambient_secret_path)?;
|
||||
let _whitespace_home = EnvVarGuard::set("CODEWHALE_HOME", " \t ");
|
||||
let resolved_config_path = codewhale_config::resolve_config_path(None)?;
|
||||
|
||||
let read = provider_secret_store_api_key(&Config::default(), ApiProvider::Deepseek);
|
||||
let saved = save_api_key("replacement-secret-sentinel")?;
|
||||
@@ -2799,7 +2800,7 @@ fn whitespace_codewhale_home_never_opens_ambient_file_secret_store() -> Result<(
|
||||
read, None,
|
||||
"whitespace must not opt tests into ambient reads"
|
||||
);
|
||||
assert_eq!(saved, SavedCredential::ConfigFile(config_path));
|
||||
assert_eq!(saved, SavedCredential::ConfigFile(resolved_config_path));
|
||||
assert_eq!(after, before, "ambient file secret store was modified");
|
||||
Ok(())
|
||||
}
|
||||
@@ -2929,7 +2930,7 @@ fn root_api_key_config_failure_restores_absent_and_existing_secret_state() -> Re
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_key_falls_back_to_config_when_isolated_file_store_is_unwritable() -> Result<()> {
|
||||
fn save_key_refuses_plaintext_config_when_isolated_file_store_is_unwritable() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
@@ -2948,11 +2949,86 @@ fn save_key_falls_back_to_config_when_isolated_file_store_is_unwritable() -> Res
|
||||
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", codewhale_home.as_os_str());
|
||||
let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
|
||||
let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
|
||||
let resolved_config_path = codewhale_config::resolve_config_path(None)?;
|
||||
|
||||
let saved = save_api_key("fallback-test-credential")?;
|
||||
assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));
|
||||
let config = fs::read_to_string(config_path)?;
|
||||
assert!(config.contains("fallback-test-credential"));
|
||||
let error = save_api_key("fallback-test-credential")
|
||||
.expect_err("secret-store failure must not downgrade to plaintext");
|
||||
let message = format!("{error:#}");
|
||||
assert!(message.contains("Secret storage"), "{message}");
|
||||
assert!(message.contains("Refusing"), "{message}");
|
||||
assert!(
|
||||
message.contains(&codewhale_config::quote_os_path(&resolved_config_path)),
|
||||
"{message}"
|
||||
);
|
||||
assert!(
|
||||
!resolved_config_path.exists(),
|
||||
"plaintext config must stay untouched"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provider_key_refuses_plaintext_config_when_secret_store_snapshot_fails() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let codewhale_home = temp_root.path().join("codewhale-home");
|
||||
let config_path = codewhale_home.join("config.toml");
|
||||
fs::create_dir_all(codewhale_home.join("secrets"))?;
|
||||
fs::write(
|
||||
codewhale_home.join("secrets/secrets.json"),
|
||||
"not valid json",
|
||||
)?;
|
||||
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &codewhale_home);
|
||||
let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_path);
|
||||
let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
|
||||
let resolved_config_path = codewhale_config::resolve_config_path(None)?;
|
||||
let identity = ProviderIdentity {
|
||||
provider: ApiProvider::Openrouter,
|
||||
key: ApiProvider::Openrouter.as_str().to_string(),
|
||||
exact_id: Some(ApiProvider::Openrouter.as_str().to_string()),
|
||||
};
|
||||
|
||||
let error = save_api_key_for_identity(&identity, &Config::default(), "provider-fallback-key")
|
||||
.expect_err("provider key must not downgrade to plaintext");
|
||||
let message = format!("{error:#}");
|
||||
assert!(message.contains("snapshot"), "{message}");
|
||||
assert!(
|
||||
message.contains(&codewhale_config::quote_os_path(&resolved_config_path)),
|
||||
"{message}"
|
||||
);
|
||||
assert!(
|
||||
!resolved_config_path.exists(),
|
||||
"plaintext config must stay untouched"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_codewhale_home_key_save_creates_no_workspace_state() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let relative_home = PathBuf::from(format!(
|
||||
".codewhale-relative-home-{}-{}",
|
||||
std::process::id(),
|
||||
SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos()
|
||||
));
|
||||
assert!(!relative_home.exists());
|
||||
let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", &relative_home);
|
||||
let _config_path = EnvVarGuard::remove("CODEWHALE_CONFIG_PATH");
|
||||
let _legacy_config_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
|
||||
let _backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "file");
|
||||
|
||||
let error = save_api_key("never-persisted")
|
||||
.expect_err("relative CODEWHALE_HOME must fail before persistence");
|
||||
let message = format!("{error:#}");
|
||||
assert!(message.contains("CODEWHALE_HOME"), "{message}");
|
||||
assert!(message.contains("absolute"), "{message}");
|
||||
assert!(
|
||||
!relative_home.exists(),
|
||||
"relative home must not create workspace state"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4816,7 +4892,7 @@ fn codewhale_config_path_env_wins_over_legacy_env() -> Result<()> {
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", &legacy);
|
||||
}
|
||||
|
||||
assert_eq!(env_config_path().unwrap(), preferred);
|
||||
assert_eq!(env_config_path().unwrap().unwrap(), preferred);
|
||||
|
||||
unsafe {
|
||||
EnvGuard::restore_var("CODEWHALE_CONFIG_PATH", prev_codewhale);
|
||||
@@ -4900,7 +4976,7 @@ fn test_load_uses_tilde_expanded_deepseek_config_path() -> Result<()> {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {
|
||||
fn missing_env_config_path_does_not_fall_back_to_a_different_home_file() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
@@ -4927,7 +5003,69 @@ fn test_load_falls_back_to_home_config_when_env_path_missing() -> Result<()> {
|
||||
}
|
||||
|
||||
let config = Config::load(None, None)?;
|
||||
assert_eq!(config.api_key.as_deref(), Some("home-key"));
|
||||
assert_eq!(
|
||||
config.api_key, None,
|
||||
"reads must honor the same missing env target that writes will create"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_uses_the_same_missing_absolute_env_config_path() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let config_path = temp_root.path().join("nested/config.toml");
|
||||
let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_path);
|
||||
let _legacy_config_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
|
||||
let identity = ProviderIdentity {
|
||||
provider: ApiProvider::Openrouter,
|
||||
key: ApiProvider::Openrouter.as_str().to_string(),
|
||||
exact_id: Some(ApiProvider::Openrouter.as_str().to_string()),
|
||||
};
|
||||
|
||||
let written =
|
||||
save_provider_model_for_identity(&identity, &Config::default(), "round-trip-model")?;
|
||||
assert_eq!(written, config_path);
|
||||
let loaded = Config::load(None, None)?;
|
||||
assert_eq!(
|
||||
loaded
|
||||
.provider_config_for(ApiProvider::Openrouter)
|
||||
.and_then(|provider| provider.model.as_deref()),
|
||||
Some("round-trip-model")
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn relative_config_env_is_a_load_error() -> Result<()> {
|
||||
let _lock = lock_test_env();
|
||||
let temp_root = tempfile::tempdir()?;
|
||||
let _guard = EnvGuard::new(temp_root.path());
|
||||
let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", ".codewhale/config.toml");
|
||||
let _legacy_config_path = EnvVarGuard::remove("DEEPSEEK_CONFIG_PATH");
|
||||
|
||||
let error = Config::load(None, None).expect_err("relative config path must fail closed");
|
||||
let message = format!("{error:#}");
|
||||
assert!(message.contains("CODEWHALE_CONFIG_PATH"), "{message}");
|
||||
assert!(message.contains("absolute"), "{message}");
|
||||
|
||||
for error in [
|
||||
default_config_path().expect_err("default path must preserve the override error"),
|
||||
resolve_load_config_path(None)
|
||||
.expect_err("load-path helper must preserve the override error"),
|
||||
ensure_config_file_exists(None)
|
||||
.expect_err("first-run config creation must preserve the override error"),
|
||||
] {
|
||||
let message = format!("{error:#}");
|
||||
assert!(message.contains("CODEWHALE_CONFIG_PATH"), "{message}");
|
||||
assert!(message.contains("absolute"), "{message}");
|
||||
assert!(!message.contains("home directory not found"), "{message}");
|
||||
}
|
||||
let error = env_config_path().expect_err("env helper must preserve the override error");
|
||||
let message = error.to_string();
|
||||
assert!(message.contains("CODEWHALE_CONFIG_PATH"), "{message}");
|
||||
assert!(workspace_trust_config_candidate_paths().is_empty());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4979,9 +5117,10 @@ fn test_save_api_key_doesnt_match_similar_keys() -> Result<()> {
|
||||
&config_path,
|
||||
"api_key_backup = \"old\"\napi_key = \"current\"\n",
|
||||
)?;
|
||||
let resolved_config_path = codewhale_config::resolve_config_path(None)?;
|
||||
|
||||
let saved = save_api_key("new-key")?;
|
||||
assert_eq!(saved, SavedCredential::ConfigFile(config_path.clone()));
|
||||
assert_eq!(saved, SavedCredential::ConfigFile(resolved_config_path));
|
||||
|
||||
let contents = fs::read_to_string(&config_path)?;
|
||||
assert!(contents.contains("api_key_backup = \"old\""));
|
||||
@@ -8960,9 +9099,10 @@ fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {
|
||||
let config_path = temp_root.join(".deepseek").join("config.toml");
|
||||
let _config_path = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", config_path.as_os_str());
|
||||
let _secret_backend = EnvVarGuard::set("CODEWHALE_SECRET_BACKEND", "local");
|
||||
let resolved_config_path = codewhale_config::resolve_config_path(None)?;
|
||||
|
||||
let path = save_api_key_for(ApiProvider::Openrouter, "or-saved-key")?;
|
||||
assert_eq!(path, config_path);
|
||||
assert_eq!(path, resolved_config_path);
|
||||
let contents = fs::read_to_string(&path)?;
|
||||
let parsed: toml::Value = toml::from_str(&contents)?;
|
||||
assert_eq!(
|
||||
@@ -8975,7 +9115,7 @@ fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {
|
||||
);
|
||||
// Re-saving must not duplicate or wipe sibling tables.
|
||||
let novita_path = save_api_key_for(ApiProvider::Novita, "novita-saved-key")?;
|
||||
assert_eq!(novita_path, path);
|
||||
assert_eq!(novita_path.canonicalize()?, path.canonicalize()?);
|
||||
let contents = fs::read_to_string(&path)?;
|
||||
let parsed: toml::Value = toml::from_str(&contents)?;
|
||||
assert_eq!(
|
||||
@@ -9002,7 +9142,10 @@ fn save_api_key_for_openrouter_writes_provider_table() -> Result<()> {
|
||||
(ApiProvider::Siliconflow, "sf-saved-key"),
|
||||
(ApiProvider::Sglang, "sglang-saved-key"),
|
||||
] {
|
||||
assert_eq!(save_api_key_for(provider, key)?, path);
|
||||
assert_eq!(
|
||||
save_api_key_for(provider, key)?.canonicalize()?,
|
||||
path.canonicalize()?
|
||||
);
|
||||
}
|
||||
let contents = fs::read_to_string(&path)?;
|
||||
let parsed: toml::Value = toml::from_str(&contents)?;
|
||||
|
||||
@@ -427,7 +427,7 @@ pub(crate) fn config_toml_path(config_path: Option<&Path>) -> anyhow::Result<Pat
|
||||
if let Some(path) = config_path {
|
||||
return Ok(expand_path(path.to_string_lossy().as_ref()));
|
||||
}
|
||||
crate::config::resolve_load_config_path(None)
|
||||
crate::config::resolve_load_config_path(None)?
|
||||
.context("failed to resolve the active config.toml path")
|
||||
}
|
||||
|
||||
@@ -640,11 +640,17 @@ mod tests {
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", &legacy);
|
||||
}
|
||||
|
||||
assert_eq!(config_toml_path(None).unwrap(), preferred);
|
||||
let expected = preferred
|
||||
.parent()
|
||||
.expect("preferred path has a parent")
|
||||
.canonicalize()
|
||||
.expect("preferred parent should canonicalize")
|
||||
.join("preferred.toml");
|
||||
assert_eq!(config_toml_path(None).unwrap(), expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_toml_path_uses_existing_home_fallback_when_env_target_is_missing() {
|
||||
fn config_toml_path_keeps_missing_env_target_authoritative() {
|
||||
let temp_root = temp_root("codewhale-config-path-missing-env-fallback");
|
||||
let home_config = temp_root.join(".codewhale").join("config.toml");
|
||||
fs::create_dir_all(home_config.parent().unwrap()).unwrap();
|
||||
@@ -656,7 +662,8 @@ mod tests {
|
||||
env::set_var("DEEPSEEK_CONFIG_PATH", &missing_env);
|
||||
}
|
||||
|
||||
assert_eq!(config_toml_path(None).unwrap(), home_config);
|
||||
assert_eq!(config_toml_path(None).unwrap(), missing_env);
|
||||
assert!(home_config.exists());
|
||||
assert!(!missing_env.exists());
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ pub(crate) struct DoctorPathReport {
|
||||
impl DoctorPathReport {
|
||||
pub(crate) fn resolve(config_override: Option<&Path>) -> Result<Self> {
|
||||
let home = codewhale_paths::codewhale_home()
|
||||
.map_err(anyhow::Error::new)?
|
||||
.context("could not resolve the canonical Codewhale state root")?;
|
||||
let config = match config_override {
|
||||
Some(path) => codewhale_config::resolve_config_path(Some(path.to_path_buf()))
|
||||
|
||||
+10
-2
@@ -8893,8 +8893,16 @@ fn merge_user_workspace_config(
|
||||
let allow_shell_before = config.allow_shell;
|
||||
let allow_shell_from_env = std::env::var_os("CODEWHALE_ALLOW_SHELL").is_some()
|
||||
|| std::env::var_os("DEEPSEEK_ALLOW_SHELL").is_some();
|
||||
let Some(path) = crate::config::resolve_load_config_path(config_path) else {
|
||||
return;
|
||||
let path = match crate::config::resolve_load_config_path(config_path) {
|
||||
Ok(Some(path)) => path,
|
||||
Ok(None) => return,
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
error = %error,
|
||||
"failed to resolve workspace config overlay; refusing to substitute another file"
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let Ok(raw) = std::fs::read_to_string(path) else {
|
||||
return;
|
||||
|
||||
@@ -614,7 +614,7 @@ pub async fn run_http_server(
|
||||
}
|
||||
|
||||
fn fallback_sessions_dir() -> PathBuf {
|
||||
if let Some(home) = codewhale_paths::codewhale_home_override() {
|
||||
if let Some(home) = codewhale_paths::codewhale_home_override().ok().flatten() {
|
||||
return home.join("sessions");
|
||||
}
|
||||
codewhale_paths::legacy_deepseek_home()
|
||||
|
||||
@@ -216,7 +216,7 @@ pub(crate) fn log_directory() -> Option<PathBuf> {
|
||||
// check the env var directly rather than codewhale_home()'s Ok/Err because
|
||||
// that helper succeeds (returns $HOME/.codewhale) even when the override is
|
||||
// unset, which would short-circuit the legacy fallback below.
|
||||
if let Some(home) = codewhale_paths::codewhale_home_override() {
|
||||
if let Some(home) = codewhale_paths::codewhale_home_override().ok().flatten() {
|
||||
return Some(home.join("logs"));
|
||||
}
|
||||
let resolve = |base: PathBuf| -> Option<PathBuf> {
|
||||
|
||||
@@ -1869,7 +1869,7 @@ pub fn default_tasks_dir() -> PathBuf {
|
||||
return PathBuf::from(path);
|
||||
}
|
||||
}
|
||||
if let Some(home) = codewhale_paths::codewhale_home_override() {
|
||||
if let Some(home) = codewhale_paths::codewhale_home_override().ok().flatten() {
|
||||
return home.join("tasks");
|
||||
}
|
||||
codewhale_paths::user_home()
|
||||
|
||||
@@ -265,7 +265,7 @@ fn configured_heavy_command_limit() -> usize {
|
||||
}
|
||||
|
||||
fn admission_root() -> PathBuf {
|
||||
if let Some(home) = codewhale_paths::codewhale_home_override() {
|
||||
if let Some(home) = codewhale_paths::codewhale_home_override().ok().flatten() {
|
||||
return home.join("resource-admission");
|
||||
}
|
||||
if let Some(home) = codewhale_paths::user_home() {
|
||||
|
||||
@@ -2597,7 +2597,8 @@ impl App {
|
||||
return Err("the root approval policy is not editable".to_string());
|
||||
}
|
||||
|
||||
let active_config_path = crate::config::resolve_load_config_path(self.config_path.clone());
|
||||
let active_config_path = crate::config::resolve_load_config_path(self.config_path.clone())
|
||||
.map_err(|error| error.to_string())?;
|
||||
// The posture commit, the root-key release, and the rollback are one
|
||||
// critical section. Two `Settings::transact` calls would expose the
|
||||
// uncommitted middle state — a concurrent writer (a queued startup-default
|
||||
|
||||
@@ -69,8 +69,18 @@ impl App {
|
||||
true
|
||||
}
|
||||
crate::config::ApprovalPolicyControl::RootConfig => {
|
||||
let active_config_path =
|
||||
crate::config::resolve_load_config_path(config_path.clone());
|
||||
let active_config_path = match crate::config::resolve_load_config_path(
|
||||
config_path.clone(),
|
||||
) {
|
||||
Ok(path) => path,
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
error = %error,
|
||||
"could not resolve the active config path for legacy policy migration"
|
||||
);
|
||||
None
|
||||
}
|
||||
};
|
||||
match crate::config_persistence::persist_unset_root_key(
|
||||
active_config_path.as_deref(),
|
||||
"approval_policy",
|
||||
|
||||
@@ -3386,7 +3386,7 @@ fn legacy_yolo_migrates_root_policy_to_agent_full_access() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_yolo_migrates_the_actual_fallback_config_not_a_missing_env_path() {
|
||||
fn legacy_yolo_honors_a_missing_explicit_config_path_without_home_fallback() {
|
||||
let _env_lock = lock_test_env();
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let home = tmp.path().join("home");
|
||||
@@ -3413,8 +3413,8 @@ fn legacy_yolo_migrates_the_actual_fallback_config_not_a_missing_env_path() {
|
||||
let _deepseek_config = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &missing_override);
|
||||
let _approval_env = EnvVarGuard::remove("DEEPSEEK_APPROVAL_POLICY");
|
||||
|
||||
let config = Config::load(None, None).expect("load fallback config");
|
||||
assert_eq!(config.approval_policy.as_deref(), Some("on-request"));
|
||||
let config = Config::load(None, None).expect("load explicit missing config");
|
||||
assert_eq!(config.approval_policy, None);
|
||||
let mut options = test_options(false);
|
||||
options.start_in_agent_mode = false;
|
||||
options.workspace = workspace;
|
||||
@@ -3427,11 +3427,11 @@ fn legacy_yolo_migrates_the_actual_fallback_config_not_a_missing_env_path() {
|
||||
assert!(!app.approval_policy_locked());
|
||||
assert!(
|
||||
!missing_override.exists(),
|
||||
"migration must not create the missing DEEPSEEK_CONFIG_PATH target"
|
||||
"settings migration must not create an unrelated config document"
|
||||
);
|
||||
let saved_home_config = std::fs::read_to_string(&home_config).expect("saved fallback config");
|
||||
let saved_home_config = std::fs::read_to_string(&home_config).expect("untouched home config");
|
||||
assert!(saved_home_config.contains("# actual fallback"));
|
||||
assert!(!saved_home_config.contains("approval_policy"));
|
||||
assert!(saved_home_config.contains("approval_policy = \"on-request\""));
|
||||
let saved_settings =
|
||||
std::fs::read_to_string(&override_settings).expect("normalized override settings");
|
||||
assert!(saved_settings.contains("default_mode = \"agent\""));
|
||||
|
||||
+11
-12
@@ -8813,11 +8813,11 @@ pub(crate) fn apply_engine_error_to_app(
|
||||
app.onboarding_needs_api_key = true;
|
||||
app.onboarding = OnboardingState::Provider;
|
||||
let provider = app.api_provider;
|
||||
let config_path = crate::config::resolve_load_config_path(app.config_path.clone())
|
||||
.map_or_else(
|
||||
|| "~/.codewhale/config.toml".to_string(),
|
||||
|path| path.display().to_string(),
|
||||
);
|
||||
let config_path = match crate::config::resolve_load_config_path(app.config_path.clone()) {
|
||||
Ok(Some(path)) => path.display().to_string(),
|
||||
Ok(None) => "~/.codewhale/config.toml".to_string(),
|
||||
Err(error) => error.to_string(),
|
||||
};
|
||||
app.status_message = Some(
|
||||
tr(app.ui_locale, MessageId::OnboardApiKeyRejectedEnv)
|
||||
.replace("{provider}", provider.as_str())
|
||||
@@ -15899,13 +15899,12 @@ fn apply_setup_runtime_preset(
|
||||
settings.default_mode = preset.default_mode().to_string();
|
||||
settings.permission_posture = Some(preset.permission_posture().to_string());
|
||||
|
||||
// Persist into the same file Config::load actually selected. When an env
|
||||
// override names a missing file, reads intentionally fall back to an
|
||||
// existing home config; writing to the missing override would otherwise
|
||||
// leave the controlling key untouched and shadow the home config on the
|
||||
// next launch.
|
||||
let selected_config_path = crate::config::resolve_load_config_path(app.config_path.clone())
|
||||
.or_else(|| app.config_path.clone());
|
||||
// Persist into the same file Config::load actually selected. A missing
|
||||
// explicit env target remains authoritative for both reads and writes;
|
||||
// an invalid target fails here instead of selecting a different file.
|
||||
let selected_config_path =
|
||||
crate::config::resolve_load_config_path(app.config_path.clone())?
|
||||
.or_else(|| app.config_path.clone());
|
||||
let config_path =
|
||||
crate::config_persistence::config_toml_path(selected_config_path.as_deref())
|
||||
.context("failed to resolve config path")?;
|
||||
|
||||
Reference in new issue
Block a user