test: improve vault password file errors by propagating up

This commit is contained in:
2026-06-04 15:32:31 -06:00
parent b1696c3425
commit e54a2e42c9
9 changed files with 243 additions and 29 deletions
+10 -7
View File
@@ -137,13 +137,16 @@ pub(super) fn session_completer(current: &OsStr) -> Vec<CompletionCandidate> {
pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> { pub(super) fn secrets_completer(current: &OsStr) -> Vec<CompletionCandidate> {
let cur = current.to_string_lossy(); let cur = current.to_string_lossy();
match load_app_config_for_completion() { match load_app_config_for_completion() {
Ok(app_config) => Vault::init(&app_config) Ok(app_config) => match Vault::init(&app_config) {
.list_secrets(false) Ok(vault) => vault
.unwrap_or_default() .list_secrets(false)
.into_iter() .unwrap_or_default()
.filter(|s| s.starts_with(&*cur)) .into_iter()
.map(CompletionCandidate::new) .filter(|s| s.starts_with(&*cur))
.collect(), .map(CompletionCandidate::new)
.collect(),
Err(_) => vec![],
},
Err(_) => vec![], Err(_) => vec![],
} }
} }
+28
View File
@@ -10,6 +10,7 @@ use clap::ValueHint;
use clap::{Parser, crate_authors, crate_description, crate_version}; use clap::{Parser, crate_authors, crate_description, crate_version};
use clap_complete::ArgValueCompleter; use clap_complete::ArgValueCompleter;
use is_terminal::IsTerminal; use is_terminal::IsTerminal;
use std::collections::HashSet;
use std::io::{Read, stdin}; use std::io::{Read, stdin};
#[derive(Parser, Debug)] #[derive(Parser, Debug)]
@@ -163,6 +164,18 @@ pub struct Cli {
} }
impl Cli { impl Cli {
pub fn skills(&self) -> Vec<String> {
let mut seen = HashSet::new();
let mut out = Vec::with_capacity(self.skill.len());
for name in &self.skill {
if seen.insert(name.clone()) {
out.push(name.clone());
}
}
out
}
pub fn text(&self) -> Result<Option<String>> { pub fn text(&self) -> Result<Option<String>> {
let mut stdin_text = String::new(); let mut stdin_text = String::new();
if !stdin().is_terminal() { if !stdin().is_terminal() {
@@ -323,6 +336,21 @@ mod tests {
); );
} }
#[test]
fn skills_method_dedupes_preserving_first_occurrence() {
let cli = parse(&[
"--skill", "alpha", "--skill", "beta", "--skill", "alpha", "--skill", "gamma",
"--skill", "beta",
]);
assert_eq!(cli.skills(), vec!["alpha", "beta", "gamma"]);
}
#[test]
fn skills_method_returns_empty_when_no_flags() {
assert!(parse(&[]).skills().is_empty());
}
#[test] #[test]
fn parse_file_flag_single() { fn parse_file_flag_single() {
let cli = parse(&["-f", "file.txt", "question"]); let cli = parse(&["-f", "file.txt", "question"]);
+1 -1
View File
@@ -43,7 +43,7 @@ impl AppState {
start_mcp_servers: bool, start_mcp_servers: bool,
abort_signal: AbortSignal, abort_signal: AbortSignal,
) -> Result<Self> { ) -> Result<Self> {
let vault = Arc::new(Vault::init(&config)); let vault = Arc::new(Vault::init(&config)?);
let mcp_registry = McpRegistry::init( let mcp_registry = McpRegistry::init(
log_path, log_path,
+1 -1
View File
@@ -490,7 +490,7 @@ impl Config {
secrets_provider: config.secrets_provider.clone(), secrets_provider: config.secrets_provider.clone(),
..AppConfig::default() ..AppConfig::default()
}; };
let vault = Vault::init(&bootstrap_app); let vault = Vault::init(&bootstrap_app)?;
let (parsed_config, missing_secrets) = interpolate_secrets(&content, &vault)?; let (parsed_config, missing_secrets) = interpolate_secrets(&content, &vault)?;
if !missing_secrets.is_empty() && !info_flag { if !missing_secrets.is_empty() && !info_flag {
debug!( debug!(
+7 -7
View File
@@ -129,13 +129,13 @@ async fn run(
} }
}; };
if policy.skills_enabled let node_has_enabled_skills = node
&& node .enabled_skills
.tools .as_deref()
.as_deref() .map(|s| !s.is_empty())
.map(|t| !t.is_empty()) .unwrap_or(false);
.unwrap_or(false)
{ if policy.skills_enabled && node_has_enabled_skills {
let mut tools = role.enabled_tools().map(|v| v.to_vec()).unwrap_or_default(); let mut tools = role.enabled_tools().map(|v| v.to_vec()).unwrap_or_default();
for decl in skill_function_declarations() { for decl in skill_function_declarations() {
if !tools.contains(&decl.name) { if !tools.contains(&decl.name) {
+12
View File
@@ -228,6 +228,12 @@ impl GraphValidator {
)); ));
continue; continue;
} }
if let Err(e) = paths::validate_skill_name(name) {
result.error(ValidationError::new(format!(
"graph 'enabled_skills' contains an invalid skill name: '{name}': {e}"
)));
continue;
}
if let Some(reason) = check_visibility(name) { if let Some(reason) = check_visibility(name) {
result.error(ValidationError::new(format!( result.error(ValidationError::new(format!(
"graph 'enabled_skills': {reason}" "graph 'enabled_skills': {reason}"
@@ -252,6 +258,12 @@ impl GraphValidator {
)); ));
continue; continue;
} }
if let Err(e) = paths::validate_skill_name(name) {
result.error(ValidationError::new(format!(
"llm node 'enabled_skills' contains an invalid skill name: '{name}': {e}"
)));
continue;
}
if let Some(reason) = check_visibility(name) { if let Some(reason) = check_visibility(name) {
result.error(ValidationError::with_node( result.error(ValidationError::with_node(
node_id, node_id,
+7 -7
View File
@@ -113,7 +113,7 @@ async fn main() -> Result<()> {
if vault_flags { if vault_flags {
let cfg = Config::load_with_interpolation(true).await?; let cfg = Config::load_with_interpolation(true).await?;
let app_config = AppConfig::from_config(cfg)?; let app_config = AppConfig::from_config(cfg)?;
let vault = Vault::init(&app_config); let vault = Vault::init(&app_config)?;
return Vault::handle_vault_flags(cli, &vault); return Vault::handle_vault_flags(cli, &vault);
} }
@@ -197,17 +197,17 @@ async fn run(
println!("{skills}"); println!("{skills}");
return Ok(()); return Ok(());
} }
if cli.skill.len() == 1 { let skills = cli.skills();
let name = &cli.skill[0]; if skills.len() == 1 {
let name = &skills[0];
paths::validate_skill_name(name)?; paths::validate_skill_name(name)?;
if !paths::has_skill(name) { if !paths::has_skill(name) {
let app = Arc::clone(&ctx.app.config); let app = Arc::clone(&ctx.app.config);
ctx.upsert_skill(app.as_ref(), name)?; ctx.upsert_skill(app.as_ref(), name)?;
return Ok(()); return Ok(());
} }
} } else if skills.len() > 1 {
if cli.skill.len() > 1 { for name in &skills {
for name in &cli.skill {
paths::validate_skill_name(name)?; paths::validate_skill_name(name)?;
if !paths::has_skill(name) { if !paths::has_skill(name) {
bail!("Skill '{name}' is not installed"); bail!("Skill '{name}' is not installed");
@@ -327,7 +327,7 @@ async fn run(
.await?; .await?;
} }
for name in &cli.skill { for name in &cli.skills() {
ctx.load_skill_repl(name, abort_signal.clone()).await?; ctx.load_skill_repl(name, abort_signal.clone()).await?;
} }
+3 -4
View File
@@ -80,7 +80,7 @@ impl Vault {
} }
} }
pub fn init(config: &AppConfig) -> Self { pub fn init(config: &AppConfig) -> Result<Self> {
let mut provider = match &config.secrets_provider { let mut provider = match &config.secrets_provider {
Some(p) => p.clone(), Some(p) => p.clone(),
None => SupportedProvider::Local { None => SupportedProvider::Local {
@@ -92,11 +92,10 @@ impl Vault {
}; };
if let SupportedProvider::Local { provider_def } = &mut provider { if let SupportedProvider::Local { provider_def } = &mut provider {
ensure_password_file_initialized(provider_def) ensure_password_file_initialized(provider_def)?;
.expect("Failed to initialize password file");
} }
Self { provider } Ok(Self { provider })
} }
pub fn local_password_file(&self) -> Result<PathBuf> { pub fn local_password_file(&self) -> Result<PathBuf> {
+174 -2
View File
@@ -355,6 +355,19 @@ fn required_cli_preflight(label: &str, cli: &str, install_url: &str) {
} }
pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec<String>)> { pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec<String>)> {
interpolate_secrets_with(content, vault.auth_hint(), |name| {
vault.get_secret(name, false)
})
}
fn interpolate_secrets_with<F>(
content: &str,
auth_hint: Option<&'static str>,
mut get_secret: F,
) -> Result<(String, Vec<String>)>
where
F: FnMut(&str) -> Result<String>,
{
let mut missing_secrets = vec![]; let mut missing_secrets = vec![];
let mut fatal_error: Option<anyhow::Error> = None; let mut fatal_error: Option<anyhow::Error> = None;
@@ -372,7 +385,7 @@ pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec<
} }
let name = caps[1].trim(); let name = caps[1].trim();
match vault.get_secret(name, false) { match get_secret(name) {
Ok(s) => s, Ok(s) => s,
Err(e) => match e.downcast_ref::<SecretError>() { Err(e) => match e.downcast_ref::<SecretError>() {
Some(SecretError::NotFound { .. }) => { Some(SecretError::NotFound { .. }) => {
@@ -382,7 +395,7 @@ pub fn interpolate_secrets(content: &str, vault: &Vault) -> Result<(String, Vec<
Some(SecretError::AuthFailed { .. }) => { Some(SecretError::AuthFailed { .. }) => {
let base = let base =
format!("Failed to fetch secret '{name}' from vault: {e}"); format!("Failed to fetch secret '{name}' from vault: {e}");
let msg = match vault.auth_hint() { let msg = match auth_hint {
Some(hint) => format!("{base}\n\nHint: {hint}"), Some(hint) => format!("{base}\n\nHint: {hint}"),
None => base, None => base,
}; };
@@ -425,3 +438,162 @@ fn set_password_file_permissions(path: &Path) -> Result<()> {
fn set_password_file_permissions(_path: &Path) -> Result<()> { fn set_password_file_permissions(_path: &Path) -> Result<()> {
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
use anyhow::Error;
use std::cell::RefCell;
fn not_found(name: &str) -> Error {
Error::new(SecretError::NotFound {
key: name.to_string(),
provider: "test",
})
}
fn auth_failed() -> Error {
Error::new(SecretError::AuthFailed {
provider: "test",
source: anyhow!("auth failure"),
})
}
struct Calls(RefCell<Vec<String>>);
impl Calls {
fn new() -> Self {
Self(RefCell::new(Vec::new()))
}
fn record(&self, name: &str) {
self.0.borrow_mut().push(name.to_string());
}
fn snapshot(&self) -> Vec<String> {
self.0.borrow().clone()
}
}
#[test]
fn interpolates_single_secret_per_line() {
let (out, missing) =
interpolate_secrets_with("api_key={{API_KEY}}", None, |name| match name {
"API_KEY" => Ok("sk-12345".to_string()),
other => panic!("unexpected lookup: {other}"),
})
.unwrap();
assert_eq!(out, "api_key=sk-12345");
assert!(missing.is_empty());
}
#[test]
fn regex_matches_each_secret_independently_when_one_per_line() {
let calls = Calls::new();
let (out, missing) = interpolate_secrets_with("{{ONE}}\nmiddle\n{{TWO}}", None, |name| {
calls.record(name);
Ok(name.to_lowercase())
})
.unwrap();
assert_eq!(calls.snapshot(), vec!["ONE".to_string(), "TWO".to_string()]);
assert_eq!(out, "one\nmiddle\ntwo");
assert!(missing.is_empty());
}
#[test]
fn skips_comment_lines() {
let calls = Calls::new();
let (out, missing) =
interpolate_secrets_with("# api_key={{NEVER_FETCHED}}\nreal={{S}}", None, |name| {
calls.record(name);
Ok("v".to_string())
})
.unwrap();
assert_eq!(out, "# api_key={{NEVER_FETCHED}}\nreal=v");
assert!(missing.is_empty());
assert_eq!(calls.snapshot(), vec!["S".to_string()]);
}
#[test]
fn missing_secrets_become_empty_strings_and_are_reported() {
let (out, missing) = interpolate_secrets_with(
"a={{HAVE}}\nb={{MISSING_1}}\nc={{MISSING_2}}",
None,
|name| match name {
"HAVE" => Ok("present".to_string()),
missing => Err(not_found(missing)),
},
)
.unwrap();
assert_eq!(out, "a=present\nb=\nc=");
assert_eq!(
missing,
vec!["MISSING_1".to_string(), "MISSING_2".to_string()]
);
}
#[test]
fn fatal_failure_short_circuits_remaining_lines() {
let calls = Calls::new();
let result =
interpolate_secrets_with("a={{S1}}\nb={{S2}}\nc={{S3}}\nd={{S4}}", None, |name| {
calls.record(name);
match name {
"S1" => Ok("first".to_string()),
"S2" => Err(auth_failed()),
other => Ok(format!("late-{other}")),
}
});
let err = result.unwrap_err().to_string();
assert!(
err.contains("S2"),
"error should name the offending secret, got: {err}"
);
assert_eq!(
calls.snapshot(),
vec!["S1".to_string(), "S2".to_string()],
"lookups must stop at the failing secret - S3 and S4 should never be fetched"
);
}
#[test]
fn auth_failure_appends_hint_when_provided() {
let result = interpolate_secrets_with(
"k={{K}}",
Some("run `coyote --authenticate` to reauth"),
|_| Err(auth_failed()),
);
let err = result.unwrap_err().to_string();
assert!(err.contains("Hint:"), "expected hint in error, got: {err}");
assert!(
err.contains("coyote --authenticate"),
"expected hint contents, got: {err}"
);
}
#[test]
fn regex_greedy_capture_collapses_multi_secret_line_into_single_lookup() {
let calls = Calls::new();
let (out, missing) = interpolate_secrets_with("url={{URL}} key={{KEY}}", None, |name| {
calls.record(name);
Err(not_found(name))
})
.unwrap();
assert_eq!(
calls.snapshot(),
vec!["URL}} key={{KEY".to_string()],
"greedy regex spans first {{ to last }}, collapsing the whole line into one bogus lookup"
);
assert_eq!(out, "url=");
assert_eq!(missing, vec!["URL}} key={{KEY".to_string()]);
}
}