From 96903c337a9c2fc6d891d9734a03d76f4be4edf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Thu, 8 Oct 2026 12:28:06 +0200 Subject: [PATCH 1/2] fix(run): fetch every secret when run has no --only Since b703cb21, `stashbase run -p -e -- cmd` without --only skipped the secrets request and started the command with no secrets: an empty --only was read as "nothing left to fetch" instead of "every secret". An agent run still fetches only the secrets its profile binds, so an empty binding list there still means none. The "Egress-only profile" wording is now limited to agent runs; plain run always says "Environment loaded (N secrets)". --- src/handlers/run/entry.rs | 78 +++++++++++++++++++++++++++++++-------- tests/exit_status_cli.rs | 30 +++++++++++++++ 2 files changed, 93 insertions(+), 15 deletions(-) diff --git a/src/handlers/run/entry.rs b/src/handlers/run/entry.rs index ac084b5e..d252ab4c 100644 --- a/src/handlers/run/entry.rs +++ b/src/handlers/run/entry.rs @@ -1110,7 +1110,7 @@ pub async fn handle_load_env_run(args: HandleRunArgs) -> anyhow::Result<()> { }) .unwrap_or_else(|| only.clone()); - if remote_only.is_empty() { + if !needs_remote_fetch(&only, &remote_only, proxy_policy.is_some()) { let mut secrets = local_overrides.unwrap_or_default(); for secret in &mut secrets { secret.value = format_env_variable_value(secret.value.to_string()); @@ -1447,19 +1447,9 @@ async fn handle_run( if !silent { let mut success_msg = format!( - "{} {} ({} {})", + "{} {}", "✓".green_if_tty_stderr(), - if secrets.is_empty() { - "Egress-only profile" - } else { - "Environment loaded" - }, - secrets.len(), - if secrets.len() == 1 { - "secret" - } else { - "secrets" - } + loaded_message(secrets.len(), proxy_policy.is_some()) ); if print_secrets.is_some() && !is_from_file { @@ -1830,6 +1820,36 @@ fn apply_secret_bindings( /// Combines the remote fallback with local overrides. Names outside the /// profile's requested source set are discarded before a child can receive them. +/// The line `run` prints once the secrets are loaded. "Egress-only profile" +/// describes an agent profile, so plain `stashbase run` never says it. +fn loaded_message(secret_count: usize, agent_run: bool) -> String { + let label = if agent_run && secret_count == 0 { + "Egress-only profile" + } else { + "Environment loaded" + }; + let noun = if secret_count == 1 { + "secret" + } else { + "secrets" + }; + format!("{label} ({secret_count} {noun})") +} + +/// Whether `run` has to fetch secrets from Stashbase. `remote_only` is +/// `only` minus the secrets a local profile file already provides. +/// +/// An empty `only` means "every secret" for `stashbase run`, so it always +/// fetches. An agent run (`agent_run`) only ever receives the secrets its +/// profile binds, so an empty `only` there means none. +fn needs_remote_fetch(only: &[String], remote_only: &[String], agent_run: bool) -> bool { + if only.is_empty() { + !agent_run + } else { + !remote_only.is_empty() + } +} + fn merge_remote_and_local_secrets( remote: Vec, local: Vec, @@ -1870,8 +1890,9 @@ fn missing_secret_labels( #[cfg(test)] mod tests { use super::{ - apply_secret_bindings, load_run_secrets_from_file, merge_remote_and_local_secrets, - missing_secret_labels, prepare_local_run_secrets, + apply_secret_bindings, load_run_secrets_from_file, loaded_message, + merge_remote_and_local_secrets, missing_secret_labels, needs_remote_fetch, + prepare_local_run_secrets, }; use crate::models::secrets::SecretWithoutComment; use std::{ @@ -2131,6 +2152,33 @@ mod tests { assert_eq!(missing, ["GH_TOKEN (from GITHUB_TOKEN)"]); } + #[test] + fn only_an_agent_run_without_secrets_is_called_egress_only() { + assert_eq!(loaded_message(0, true), "Egress-only profile (0 secrets)"); + assert_eq!(loaded_message(1, true), "Environment loaded (1 secret)"); + assert_eq!(loaded_message(0, false), "Environment loaded (0 secrets)"); + assert_eq!(loaded_message(10, false), "Environment loaded (10 secrets)"); + } + + #[test] + fn run_without_only_fetches_every_secret() { + assert!(needs_remote_fetch(&[], &[], false)); + } + + #[test] + fn an_agent_run_never_fetches_secrets_it_does_not_bind() { + assert!(!needs_remote_fetch(&[], &[], true)); + } + + #[test] + fn requested_secrets_are_fetched_unless_a_local_file_provides_them_all() { + let only = ["GITHUB_TOKEN".to_owned()]; + for agent_run in [false, true] { + assert!(needs_remote_fetch(&only, &only, agent_run)); + assert!(!needs_remote_fetch(&only, &[], agent_run)); + } + } + #[test] fn local_overrides_replace_remote_values_without_adding_unrequested_secrets() { let merged = merge_remote_and_local_secrets( diff --git a/tests/exit_status_cli.rs b/tests/exit_status_cli.rs index ca7ed782..60caa2cc 100644 --- a/tests/exit_status_cli.rs +++ b/tests/exit_status_cli.rs @@ -412,6 +412,36 @@ fn an_api_request_that_fails_exits_1() { ); } +/// Without `--only`, `run` fetches every secret of the environment. Against +/// an API nothing listens on, that fetch fails and the command never starts. +#[test] +fn run_without_only_fetches_secrets_before_starting_the_command() { + let project = Project::new(); + + let out = project.run(&[ + "run", + "-p", + "myproj", + "-e", + "dev", + "--api-key", + "k", + "--", + "sh", + "-c", + "echo child-ran", + ]); + + check( + "run", + &out, + 1, + "Could not connect to the API", + Reported::NotAsserted, + ); + assert!(!text(&out.stdout).contains("child-ran")); +} + #[test] fn run_without_any_secrets_does_not_start_the_command_and_exits_1() { let project = Project::new(); From 283efdbe42d657be817722d798632544874a50ef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Radim=20H=C3=B6fer?= Date: Thu, 8 Oct 2026 12:31:32 +0200 Subject: [PATCH 2/2] fix(run): clear the loading spinner before printing a --json error With --json, the "no secrets found" error was printed after the spinner frame on the same line ("Loading environment...{"), so the output was not clean JSON. --- src/handlers/run/entry.rs | 12 ++++++++++-- tests/exit_status_cli.rs | 26 ++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/handlers/run/entry.rs b/src/handlers/run/entry.rs index d252ab4c..6b8bdc0b 100644 --- a/src/handlers/run/entry.rs +++ b/src/handlers/run/entry.rs @@ -1010,7 +1010,11 @@ pub async fn handle_load_env_run(args: HandleRunArgs) -> anyhow::Result<()> { }); let json_str = get_formatted_json_string(&message, false).unwrap(); - eprintln!("{}", json_str); + if let Some(ref mut spinner) = spinner { + spinner.stop_with_message(&json_str); + } else { + eprintln!("{}", json_str); + } } else if let Some(ref mut spinner) = spinner { spinner.stop_with_message(&format!( "{}\n Message: {}\n Details:\n Missing secrets: {}", @@ -1203,7 +1207,11 @@ pub async fn handle_load_env_run(args: HandleRunArgs) -> anyhow::Result<()> { }); let json_str = get_formatted_json_string(&message, false).unwrap(); - eprintln!("{}", json_str); + if let Some(ref mut spinner) = spinner { + spinner.stop_with_message(&json_str); + } else { + eprintln!("{}", json_str); + } } else { let msg = format!( "{}\n Message: {}\n Details:\n Missing secrets: {}", diff --git a/tests/exit_status_cli.rs b/tests/exit_status_cli.rs index 60caa2cc..68daa187 100644 --- a/tests/exit_status_cli.rs +++ b/tests/exit_status_cli.rs @@ -442,6 +442,32 @@ fn run_without_only_fetches_secrets_before_starting_the_command() { assert!(!text(&out.stdout).contains("child-ran")); } +/// A `--json` error replaces the loading spinner instead of being appended to +/// its line, so the error is the only thing left to parse. +#[test] +fn run_json_error_clears_the_loading_spinner() { + let project = Project::new(); + std::fs::write(project.cwd.join("secrets.env"), "OTHER=1\n").unwrap(); + + let out = project.run(&[ + "run", + "--file", + "secrets.env", + "--only", + "MISSING", + "--api-key", + "k", + "--json", + "--", + "true", + ]); + + let stderr = text(&out.stderr); + assert_eq!(out.status.code(), Some(1), "{stderr}"); + assert!(stderr.contains("no secrets found"), "{stderr}"); + assert!(!stderr.contains("Loading environment...{"), "{stderr}"); +} + #[test] fn run_without_any_secrets_does_not_start_the_command_and_exits_1() { let project = Project::new();