Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/cmd/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,10 @@ impl EntityType {
EntityType::Agent(AgentCommand {
subcommand: AgentSubcommand::Policy(_),
}) => false,
// Reads local profile files and the local config only.
EntityType::Agent(AgentCommand {
subcommand: AgentSubcommand::Profiles(_),
}) => false,
// File-only agent profiles require no Stashbase authentication.
// Remote fallback is validated after the profile and local overrides
// have been resolved.
Expand Down Expand Up @@ -208,4 +212,15 @@ mod tests {
.is_ok());
}
}

#[test]
fn agent_profiles_commands_do_not_require_an_api_key() {
for args in [
vec!["stashbase", "agent", "profiles", "list"],
vec!["stashbase", "agent", "profiles", "show", "demo"],
] {
let cli = Cli::try_parse_from(&args).unwrap();
assert!(!cli.entity_type.requires_api_key(), "{args:?}");
}
}
}
29 changes: 29 additions & 0 deletions src/exit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,16 @@ impl Exit {
}
}

/// Gives a failure that would exit 0 exit code 1 instead. `agent`
/// commands use this so a profile that fails closed is not reported to
/// scripts as a success. Successes and explicit codes are left alone.
pub fn nonzero_on_failure(self) -> Self {
match self.failure {
Some(_) if self.code == 0 => Self { code: 1, ..self },
_ => self,
}
}

/// Reports the outcome to telemetry, then exits the process. Telemetry
/// never changes the exit code.
pub fn terminate(self) -> ! {
Expand Down Expand Up @@ -185,6 +195,25 @@ mod tests {
assert_eq!(Exit::from(anyhow::Error::from(killed)).code, 1);
}

#[test]
fn nonzero_on_failure_only_replaces_a_failure_that_exits_0() {
assert_eq!(
Exit::failed(ErrorKind::Validation).nonzero_on_failure(),
Exit {
code: 1,
failure: Some(ErrorKind::Validation)
}
);
assert_eq!(Exit::ok().nonzero_on_failure(), Exit::ok());
assert_eq!(Exit::code(3).nonzero_on_failure(), Exit::code(3));
assert_eq!(
Exit::failed_with(ErrorKind::Other, 7)
.nonzero_on_failure()
.code,
7
);
}

#[test]
fn a_normal_return_after_ctrl_c_exits_130_and_nothing_else_changes() {
assert_eq!(process_exit_code(0, true), 130);
Expand Down
62 changes: 58 additions & 4 deletions src/handlers/entry/root.rs
Original file line number Diff line number Diff line change
Expand Up @@ -491,6 +491,7 @@ pub async fn handle_cli(args: Cli) -> Exit {

// Local commands such as `agent logs` do not need Stashbase authentication.
let api_key = api_key.unwrap_or_default();
let agent_failures_exit_1 = agent_failures_exit_1(&args.entity_type);

let result: anyhow::Result<Exit> = match args.entity_type {
EntityType::Whoami(WhoamiCommand { format }) => {
Expand Down Expand Up @@ -1476,7 +1477,7 @@ pub async fn handle_cli(args: Cli) -> Exit {
EntityType::Doctor(_) => unreachable!(),
};

match result {
let exit = match result {
Ok(exit) => exit,
Err(err) => {
if REQUEST_ABORTED.load(Ordering::SeqCst) {
Expand All @@ -1486,8 +1487,14 @@ pub async fn handle_cli(args: Cli) -> Exit {
eprintln!("{:?}", err);
Exit::from(err)
}
};
if agent_failures_exit_1 {
exit.nonzero_on_failure()
} else {
exit
}
} else {
let agent_failures_exit_1 = agent_failures_exit_1(&args.entity_type);
if let EntityType::Config(cmd) = args.entity_type {
if let ConfigSubcommand::Reset(_) = cmd.subcommand {
return print_error(handle_config_commands(cmd, &Config::new(), args.raw));
Expand All @@ -1502,7 +1509,12 @@ pub async fn handle_cli(args: Cli) -> Exit {
// An unreadable or malformed config file: printed, and the command
// still exits 0.
eprintln!("{:?}", err);
Exit::failed(ErrorKind::Validation)
let exit = Exit::failed(ErrorKind::Validation);
if agent_failures_exit_1 {
exit.nonzero_on_failure()
} else {
exit
}
}
}

Expand All @@ -1528,6 +1540,19 @@ fn check_exit(failed: bool) -> Exit {
}
}

/// `agent` commands exit 1 when they fail, except the bare `agent hooks`
/// invocation: Claude Code, Codex and Cursor run it before tool calls and
/// read its exit code, so its codes stay as they were.
fn agent_failures_exit_1(entity_type: &EntityType) -> bool {
match entity_type {
EntityType::Agent(crate::cmd::agent::AgentCommand {
subcommand: AgentSubcommand::Hooks(command),
}) => command.subcommand.is_some(),
EntityType::Agent(_) => true,
_ => false,
}
}

fn uses_local_dependency_hook_broker(entity_type: &EntityType) -> bool {
uses_local_dependency_hook_broker_mode(
entity_type,
Expand Down Expand Up @@ -2325,8 +2350,8 @@ fn spawn_remote_session_rotation(
#[cfg(test)]
mod tests {
use super::{
audit_binding_sources, codex_mcp_binding_header_overrides, configured_host_matches,
dependency_hooks_enabled, directory_profile_git_warning,
agent_failures_exit_1, audit_binding_sources, codex_mcp_binding_header_overrides,
configured_host_matches, dependency_hooks_enabled, directory_profile_git_warning,
ensure_replacement_session_is_compatible, infer_remote_agent_type, remote_bindings,
remote_codex_command_with_mcp_binding_headers, remote_session_rotation_delay_for,
remote_session_transport_identity, remote_source_env_names, secret_child_name,
Expand Down Expand Up @@ -2405,6 +2430,35 @@ mod tests {
));
}

#[test]
fn agent_failures_exit_1_except_the_bare_hook_invocation() {
let parse = |args: &[&str]| Cli::try_parse_from(args).unwrap().entity_type;

assert!(!agent_failures_exit_1(&parse(&[
"stashbase",
"agent",
"hooks"
])));
assert!(agent_failures_exit_1(&parse(&[
"stashbase",
"agent",
"hooks",
"deps",
"install",
"codex"
])));
assert!(agent_failures_exit_1(&parse(&[
"stashbase",
"agent",
"run",
"--profile",
"p",
"--",
"true"
])));
assert!(!agent_failures_exit_1(&parse(&["stashbase", "pull"])));
}

#[test]
fn dependency_hooks_require_an_api_key() {
let profile: AgentProfile = serde_json::from_value(serde_json::json!({
Expand Down
18 changes: 10 additions & 8 deletions tests/exit_status_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
//! They pin today's behaviour, including paths that print an error and still
//! exit 0. Changing one of those exit codes is a product decision, so a test
//! here must not change as a side effect of refactoring how the CLI exits.
//! `agent` commands are the exception: every failure exits non-zero, so a
//! profile that fails closed is not reported to scripts as a success.
//!
//! The tests that apply the macOS sandbox or bind a loopback port skip
//! themselves when the OS refuses (for example inside another sandbox).
Expand Down Expand Up @@ -185,7 +187,7 @@ fn check(label: &str, out: &Output, code: i32, needle: &str, reported: Reported)
// ---- config and profile resolution --------------------------------------

#[test]
fn a_malformed_config_toml_fails_every_command_that_reads_it_with_exit_0() {
fn a_malformed_config_toml_fails_every_command_that_reads_it() {
let project = Project::new();
project.break_config();

Expand Down Expand Up @@ -219,7 +221,7 @@ fn a_malformed_config_toml_fails_every_command_that_reads_it_with_exit_0() {
check(
"agent init",
&project.run(&["agent", "init", "p"]),
0,
1,
message,
Reported::Event("error", Some("validation")),
);
Expand Down Expand Up @@ -434,7 +436,7 @@ fn generate_prints_its_error_and_exits_0() {
// ---- agent init -----------------------------------------------------------

#[test]
fn agent_init_refuses_to_overwrite_and_exits_0() {
fn agent_init_refuses_to_overwrite_and_exits_1() {
let project = Project::new();
check(
"first",
Expand All @@ -449,7 +451,7 @@ fn agent_init_refuses_to_overwrite_and_exits_0() {
check(
"second",
&out,
0,
1,
"Refusing to overwrite",
Reported::Event("error", Some("other")),
);
Expand All @@ -458,7 +460,7 @@ fn agent_init_refuses_to_overwrite_and_exits_0() {
// ---- agent run: failures before launch -------------------------------------

#[test]
fn agent_run_failures_before_launch_print_the_error_and_exit_0() {
fn agent_run_failures_before_launch_print_the_error_and_exit_1() {
let project = Project::new();
project.write_profile("p", "egress_hosts = [\"example.com\"]\n");
project.write_profile("broken", "workspace = 1\n");
Expand Down Expand Up @@ -524,7 +526,7 @@ fn agent_run_failures_before_launch_print_the_error_and_exit_0() {
check(
label,
&project.run(args),
0,
1,
message,
Reported::Event("error", Some(kind)),
);
Expand Down Expand Up @@ -597,15 +599,15 @@ fn validation_commands_exit_1_when_a_check_fails() {
}

#[test]
fn agent_policy_test_on_a_missing_profile_prints_the_error_and_exits_0() {
fn agent_policy_test_on_a_missing_profile_prints_the_error_and_exits_1() {
let project = Project::new();

let out = project.run(&["agent", "policy", "test", "--profile", "nope"]);

check(
"agent policy test",
&out,
0,
1,
"was not found in the global or directory config",
Reported::Nothing,
);
Expand Down
Loading