From 17f0e8400589479da209dd3a580dbd5b6f6eaf24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Krist=C3=B3f=20T=C3=B3th?= Date: Tue, 7 Apr 2026 18:02:03 +0200 Subject: [PATCH] Allow setting entrypoint from CLI --- src/cli.rs | 4 +++ src/config.rs | 77 ++++++++++++++++++++++++++++++++++++++++++-- tests/integration.rs | 34 +++++++++++++++++++ 3 files changed, 112 insertions(+), 3 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index ec71640..4863dea 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -62,6 +62,10 @@ pub struct Args { #[arg(long = "bwrap-arg", value_name = "ARG", action = clap::ArgAction::Append)] pub bwrap_args: Vec, + /// Run this binary with the trailing args as its arguments + #[arg(long, value_name = "CMD")] + pub entrypoint: Option, + /// Command and arguments to run inside the sandbox #[arg(trailing_var_arg = true, allow_hyphen_values = true)] pub command_and_args: Vec, diff --git a/src/config.rs b/src/config.rs index 39d68fb..2c8199b 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,7 +21,8 @@ pub fn build(args: Args, file_config: Option) -> Result(cli: Vec, profile: &[T], globals: &[T]) -> Vec { } fn resolve_command( + cli_entrypoint: Option, mut passthrough_args: Vec, profile: &Options, globals: &Options, ) -> Result<(OsString, Vec), SandboxError> { - let entrypoint = profile.entrypoint.clone().or(globals.entrypoint.clone()); + let entrypoint = cli_entrypoint + .map(CommandValue::Simple) + .or_else(|| profile.entrypoint.clone()) + .or_else(|| globals.entrypoint.clone()); let command = profile.command.clone().or(globals.command.clone()); if let Some(ep) = entrypoint { @@ -568,6 +573,67 @@ mod tests { assert_eq!(config.command_args, vec![OsString::from("--flag")]); } + #[test] + fn build_cli_entrypoint_overrides_profile() { + let file_config = FileConfig { + profile: HashMap::from([( + "test".into(), + Options { + entrypoint: Some(CommandValue::Simple("/usr/bin/false".into())), + ..Options::default() + }, + )]), + ..FileConfig::default() + }; + let args = Args { + profile: Some("test".into()), + entrypoint: Some("/usr/bin/true".into()), + ..Args::default() + }; + let config = build(args, Some(file_config)).unwrap(); + assert_eq!(config.command, PathBuf::from("/usr/bin/true")); + assert!(config.command_args.is_empty()); + } + + #[test] + fn build_cli_entrypoint_combines_with_passthrough_args() { + let args = Args { + entrypoint: Some("/usr/bin/true".into()), + command_and_args: vec!["--flag".into(), "value".into()], + ..Args::default() + }; + let config = build(args, None).unwrap(); + assert_eq!(config.command, PathBuf::from("/usr/bin/true")); + assert_eq!( + config.command_args, + vec![OsString::from("--flag"), OsString::from("value")] + ); + } + + #[test] + fn build_cli_entrypoint_falls_back_to_config_command_defaults() { + let file_config = FileConfig { + options: Options { + command: Some(CommandValue::WithArgs(vec![ + "--default".into(), + "args".into(), + ])), + ..Options::default() + }, + ..FileConfig::default() + }; + let args = Args { + entrypoint: Some("/usr/bin/true".into()), + ..Args::default() + }; + let config = build(args, Some(file_config)).unwrap(); + assert_eq!(config.command, PathBuf::from("/usr/bin/true")); + assert_eq!( + config.command_args, + vec![OsString::from("--default"), OsString::from("args")] + ); + } + #[test] fn build_cli_command_overrides_config() { let file_config = FileConfig { @@ -754,6 +820,7 @@ mod tests { #[test] fn entrypoint_with_passthrough_args() { let (cmd, args) = resolve_command( + None, vec!["--verbose".into(), "--model".into(), "opus".into()], &Options { entrypoint: Some(CommandValue::WithArgs(vec![ @@ -784,6 +851,7 @@ mod tests { #[test] fn entrypoint_without_passthrough_uses_command_defaults() { let (cmd, args) = resolve_command( + None, vec![], &Options { entrypoint: Some(CommandValue::Simple("claude".into())), @@ -809,6 +877,7 @@ mod tests { #[test] fn entrypoint_without_passthrough_or_command() { let (cmd, args) = resolve_command( + None, vec![], &Options { entrypoint: Some(CommandValue::WithArgs(vec![ @@ -827,6 +896,7 @@ mod tests { #[test] fn profile_entrypoint_overrides_global() { let (cmd, _) = resolve_command( + None, vec![], &Options { entrypoint: Some(CommandValue::Simple("claude".into())), @@ -844,6 +914,7 @@ mod tests { #[test] fn global_entrypoint_with_profile_command() { let (cmd, args) = resolve_command( + None, vec![], &Options { command: Some(CommandValue::WithArgs(vec![ @@ -869,7 +940,7 @@ mod tests { } #[test] fn no_command_errors() { - let result = resolve_command(vec![], &Options::default(), &Options::default()); + let result = resolve_command(None, vec![], &Options::default(), &Options::default()); assert!(matches!(result, Err(SandboxError::NoCommand))); } diff --git a/tests/integration.rs b/tests/integration.rs index 73f10b5..ac1ab75 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -755,6 +755,40 @@ fn config_entrypoint_alone_without_command_or_passthrough() { ); } +#[test] +fn cli_entrypoint_appends_passthrough_args() { + let output = sandbox(&["--entrypoint", "bash"]) + .args(["--", "-c", "echo cli-entrypoint-works"]) + .output() + .expect("failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "cli-entrypoint-works", + "expected --entrypoint to receive trailing args, got: {stdout}" + ); +} + +#[test] +fn cli_entrypoint_overrides_config_entrypoint() { + let cfg = ConfigFile::new( + r#" + entrypoint = ["/bin/false"] + "#, + ); + + let output = sandbox_withconfig(&["--config", &cfg, "--entrypoint", "bash"]) + .args(["--", "-c", "echo override-works"]) + .output() + .expect("failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "override-works", + "expected CLI --entrypoint to override config entrypoint, got: {stdout}" + ); +} + #[test] fn config_command_alone_without_passthrough() { let cfg = ConfigFile::new(