diff --git a/src/config.rs b/src/config.rs index 9937a35..ef9ce96 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,11 +21,7 @@ pub fn build(args: Args, file_config: Option) -> Result(cli: Vec, profile: &[T], globals: &[T]) -> Vec { } fn resolve_command( - mut positional: Vec, - profile_cmd: Option, - globals_cmd: Option, + mut passthrough_args: Vec, + profile: &Options, + globals: &Options, ) -> (OsString, Vec) { - if !positional.is_empty() { - let cmd = positional.remove(0); - return (cmd, positional); - } - if let Some(config_cmd) = profile_cmd.or(globals_cmd) { - let parts = config_cmd.into_vec(); - let cmd = OsString::from(&parts[0]); - let args = parts[1..].iter().map(OsString::from).collect(); + let entrypoint = profile.entrypoint.clone().or(globals.entrypoint.clone()); + let command = profile.command.clone().or(globals.command.clone()); + + if let Some(ep) = entrypoint { + let (cmd, mut args) = ep.into_binary_and_args(); + + if !passthrough_args.is_empty() { + args.extend(passthrough_args); + } else if let Some(default_cmd) = command { + args.extend(default_cmd.into_vec().into_iter().map(OsString::from)); + } + return (cmd, args); } + + if !passthrough_args.is_empty() { + return (passthrough_args.remove(0), passthrough_args); + } + if let Some(config_cmd) = command { + return config_cmd.into_binary_and_args(); + } if let Ok(cmd) = std::env::var("SANDBOX_CMD") { return (OsString::from(cmd), vec![]); } @@ -210,6 +217,7 @@ pub struct Options { pub whitelist: Option, pub hardened: Option, pub no_net: Option, + pub entrypoint: Option, pub command: Option, pub dry_run: Option, pub chdir: Option, @@ -261,6 +269,13 @@ impl CommandValue { Self::WithArgs(v) => v, } } + + fn into_binary_and_args(self) -> (OsString, Vec) { + let mut parts = self.into_vec(); + let binary = OsString::from(parts.remove(0)); + let args = parts.into_iter().map(OsString::from).collect(); + (binary, args) + } } pub fn find_config_path(explicit: Option<&Path>) -> Option { @@ -334,6 +349,7 @@ mod tests { [profile.claude] rw = ["/home/user/.config/claude"] ro = ["/etc/claude", "/etc/shared"] + entrypoint = ["claude", "--dangerously-skip-permissions"] command = ["bash", "-c", "echo hi"] [profile.codex] @@ -380,6 +396,14 @@ mod tests { ); } + #[test] + fn profile_entrypoint_with_args() { + assert_command( + &CONFIG.profile["claude"].entrypoint, + &["claude", "--dangerously-skip-permissions"], + ); + } + #[test] fn profile_command_with_args() { assert_command( @@ -675,6 +699,117 @@ mod tests { )); } + #[test] + fn entrypoint_with_passthrough_args() { + let (cmd, args) = resolve_command( + vec!["--verbose".into(), "--model".into(), "opus".into()], + &Options { + entrypoint: Some(CommandValue::WithArgs(vec![ + "claude".into(), + "--dangerously-skip-permissions".into(), + ])), + command: Some(CommandValue::Simple("--default-flag".into())), + ..Options::default() + }, + &Options::default(), + ); + assert_eq!(cmd, "claude"); + assert_eq!( + args, + vec![ + "--dangerously-skip-permissions", + "--verbose", + "--model", + "opus" + ] + .into_iter() + .map(OsString::from) + .collect::>() + ); + } + + #[test] + fn entrypoint_without_passthrough_uses_command_defaults() { + let (cmd, args) = resolve_command( + vec![], + &Options { + entrypoint: Some(CommandValue::Simple("claude".into())), + command: Some(CommandValue::WithArgs(vec![ + "--dangerously-skip-permissions".into(), + "--verbose".into(), + ])), + ..Options::default() + }, + &Options::default(), + ); + assert_eq!(cmd, "claude"); + assert_eq!( + args, + vec!["--dangerously-skip-permissions", "--verbose"] + .into_iter() + .map(OsString::from) + .collect::>() + ); + } + + #[test] + fn entrypoint_without_passthrough_or_command() { + let (cmd, args) = resolve_command( + vec![], + &Options { + entrypoint: Some(CommandValue::WithArgs(vec![ + "claude".into(), + "--dangerously-skip-permissions".into(), + ])), + ..Options::default() + }, + &Options::default(), + ); + assert_eq!(cmd, "claude"); + assert_eq!(args, vec![OsString::from("--dangerously-skip-permissions")]); + } + + #[test] + fn profile_entrypoint_overrides_global() { + let (cmd, _) = resolve_command( + vec![], + &Options { + entrypoint: Some(CommandValue::Simple("claude".into())), + ..Options::default() + }, + &Options { + entrypoint: Some(CommandValue::Simple("codex".into())), + ..Options::default() + }, + ); + assert_eq!(cmd, "claude"); + } + + #[test] + fn global_entrypoint_with_profile_command() { + let (cmd, args) = resolve_command( + vec![], + &Options { + command: Some(CommandValue::WithArgs(vec![ + "--dangerously-skip-permissions".into(), + "--verbose".into(), + ])), + ..Options::default() + }, + &Options { + entrypoint: Some(CommandValue::Simple("claude".into())), + ..Options::default() + }, + ); + assert_eq!(cmd, "claude"); + assert_eq!( + args, + vec!["--dangerously-skip-permissions", "--verbose"] + .into_iter() + .map(OsString::from) + .collect::>() + ); + } fn assert_paths(actual: &[PathBuf], expected: &[&str]) { let expected: Vec = expected.iter().map(PathBuf::from).collect(); assert_eq!(actual, &expected); diff --git a/tests/integration.rs b/tests/integration.rs index e18eb40..04f755f 100644 --- a/tests/integration.rs +++ b/tests/integration.rs @@ -15,10 +15,28 @@ fn sandbox(extra_args: &[&str]) -> Command { cmd } -fn write_config(dir: &TempDir, content: &str) -> String { - let path = dir.path().join("config.toml"); - fs::write(&path, content).expect("failed to write config"); - path.to_str().unwrap().to_string() +struct ConfigFile { + _dir: TempDir, + path: String, +} + +impl ConfigFile { + fn new(content: &str) -> Self { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("config.toml"); + fs::write(&path, content).expect("failed to write config"); + Self { + _dir: dir, + path: path.to_str().unwrap().to_string(), + } + } +} + +impl std::ops::Deref for ConfigFile { + type Target = str; + fn deref(&self) -> &str { + &self.path + } } fn read_sid_from_stat(stat: &str) -> u32 { @@ -582,8 +600,7 @@ fn config_missing_file_errors() { #[test] fn config_invalid_toml_errors() { - let dir = TempDir::new().unwrap(); - let cfg = write_config(&dir, "not valid {{{{ toml"); + let cfg = ConfigFile::new("not valid {{{{ toml"); let output = sandbox_withconfig(&["--config", &cfg]) .args(["--", "true"]) @@ -600,8 +617,7 @@ fn config_invalid_toml_errors() { #[test] fn config_unknown_key_errors() { - let dir = TempDir::new().unwrap(); - let cfg = write_config(&dir, "hardened = true\nbogus = \"nope\"\n"); + let cfg = ConfigFile::new("hardened = true\nbogus = \"nope\"\n"); let output = sandbox_withconfig(&["--config", &cfg]) .args(["--", "true"]) @@ -677,6 +693,109 @@ fn bwrap_arg_setenv_passes_through() { ); } +#[test] +fn config_entrypoint_appends_passthrough_args() { + let cfg = ConfigFile::new( + r#" + [profile.test] + entrypoint = ["bash", "-c"] + "#, + ); + + let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"]) + .args(["--", "echo entrypoint-works"]) + .output() + .expect("failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "entrypoint-works", + "expected passthrough args appended to entrypoint, got: {stdout}" + ); +} + +#[test] +fn config_entrypoint_falls_back_to_command_defaults() { + let cfg = ConfigFile::new( + r#" + [profile.test] + entrypoint = ["bash", "-c"] + command = ["echo default-args"] + "#, + ); + + let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"]) + .output() + .expect("failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "default-args", + "expected command defaults when no passthrough args, got: {stdout}" + ); +} + +#[test] +fn config_entrypoint_alone_without_command_or_passthrough() { + let cfg = ConfigFile::new( + r#" + [profile.test] + entrypoint = ["bash", "-c", "echo entrypoint-only"] + "#, + ); + + let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"]) + .output() + .expect("failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "entrypoint-only", + "expected entrypoint to run on its own, got: {stdout}" + ); +} + +#[test] +fn config_command_alone_without_passthrough() { + let cfg = ConfigFile::new( + r#" + [profile.test] + command = ["bash", "-c", "echo command-only"] + "#, + ); + + let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"]) + .output() + .expect("failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "command-only", + "expected config command to run on its own, got: {stdout}" + ); +} + +#[test] +fn config_command_replaced_by_passthrough() { + let cfg = ConfigFile::new( + r#" + [profile.test] + command = ["bash", "-c", "echo should-not-see-this"] + "#, + ); + + let output = sandbox_withconfig(&["--config", &cfg, "--profile", "test"]) + .args(["--", "bash", "-c", "echo replaced"]) + .output() + .expect("failed to execute"); + + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + stdout, "replaced", + "expected passthrough to replace config command, got: {stdout}" + ); +} + #[test] fn mask_nonexistent_path_becomes_tmpfs() { let dir = TempDir::new().unwrap();