diff --git a/crates/colibri-daemon/src/spawner.rs b/crates/colibri-daemon/src/spawner.rs index e64b524..a0c1d3a 100644 --- a/crates/colibri-daemon/src/spawner.rs +++ b/crates/colibri-daemon/src/spawner.rs @@ -185,6 +185,41 @@ fn priv_wrap( } } +/// Directories searched to resolve a bare wrapper name (`sudo`, `jexec`, `mdo`) +/// to an absolute path. A daemon must not depend on an inherited `PATH` for its +/// privileged wrappers: under `daemon(8)`/rc the `PATH` is often empty or +/// reordered, so `execvp` either misses the binary (`ENOENT`) or hits a +/// non-executable same-named entry first and returns `EACCES` — the exact spawn +/// failure seen on FreeBSD even though the same command runs fine from a shell. +const WRAPPER_SEARCH_DIRS: &[&str] = &[ + "/usr/local/sbin", + "/usr/local/bin", + "/usr/sbin", + "/usr/bin", + "/sbin", + "/bin", +]; + +/// Resolve a program name to an absolute executable path. Names already +/// containing a `/` are returned unchanged. Bare names are looked up in +/// `WRAPPER_SEARCH_DIRS`, returning the first regular executable file found. +/// Falls back to the original name (letting the OS report the error) when +/// nothing matches. +fn resolve_program(program: &str) -> String { + if program.contains('/') { + return program.to_string(); + } + for dir in WRAPPER_SEARCH_DIRS { + let candidate = Path::new(dir).join(program); + if let Ok(meta) = std::fs::metadata(&candidate) { + if meta.is_file() && meta.permissions().mode() & 0o111 != 0 { + return candidate.to_string_lossy().into_owned(); + } + } + } + program.to_string() +} + fn jail_stage_root(jail: &JailConfig) -> Option<&str> { jail.root_path.as_deref().or(jail.path.as_deref()) } @@ -259,7 +294,15 @@ async fn spawn_prepared_child( prepared: &PreparedSpawnCommand, agent_config: &AgentSpawnConfig, ) -> Result<(Child, Option), SpawnerError> { - let mut cmd = Command::new(&prepared.program); + let program = resolve_program(&prepared.program); + info!( + program = %program, + requested = %prepared.program, + args = ?prepared.argv, + path = %std::env::var("PATH").unwrap_or_default(), + "spawning agent subprocess" + ); + let mut cmd = Command::new(&program); cmd.args(&prepared.argv) .envs(&prepared.env) .stdin(Stdio::null()) @@ -788,6 +831,29 @@ mod jail_tests { v.iter().map(|s| s.to_string()).collect() } + #[test] + fn resolve_program_passes_through_absolute_paths() { + assert_eq!(resolve_program("/usr/sbin/jexec"), "/usr/sbin/jexec"); + assert_eq!(resolve_program("./rel/path"), "./rel/path"); + } + + #[test] + fn resolve_program_absolutizes_a_known_bare_name() { + // `sh` exists and is executable on any unix test host. Resolution must + // yield an absolute path under one of the search dirs. + let resolved = resolve_program("sh"); + assert!( + resolved.starts_with('/') && resolved.ends_with("/sh"), + "expected absolute path to sh, got {resolved}" + ); + } + + #[test] + fn resolve_program_falls_back_to_bare_name_when_missing() { + let missing = "definitely-not-a-real-wrapper-xyz"; + assert_eq!(resolve_program(missing), missing); + } + #[test] fn no_jail_returns_command_unchanged() { let (exe, a) = jail_wrap(