choreo-daemon 0.1.0

Agentic coding assistant — daemon, TUI, and bridges
use super::{
    Tool, ToolExecError,
    context::ToolContext,
    shell_util::{format_shell_output, resolve_workdir, run_shell_streaming, spawn_with_watchdog},
};
use choreo_keystore::ServiceCredential;
use crossbeam_channel;
use schemars::JsonSchema;
use serde::Deserialize;
use std::path::{Path, PathBuf};

#[derive(Debug, Deserialize, JsonSchema)]
pub struct ExecArgs {
    /// Program to execute directly (not a shell command). This is not parsed
    /// by a shell — shell operators, globs, and variables won't work. Use the
    /// `sh`, `nushell`, or `fish` tools for shell commands.
    pub command: String,
    /// Literal arguments passed to the program (not shell-parsed)
    #[serde(default)]
    pub args: Vec<String>,
    /// Working directory for the command (relative to the session working directory, or absolute)
    pub workdir: Option<String>,
    /// Timeout in milliseconds (default 30000; capped by outer tool deadline)
    pub timeout: Option<u64>,
}

pub(crate) struct Exec;

impl Tool for Exec {
    type Args = ExecArgs;
    type Return = String;
    type Error = ToolExecError;

    fn name(&self) -> &'static str {
        "exec"
    }

    fn group(&self) -> &'static str {
        "shell"
    }

    fn description(&self) -> &'static str {
        "Execute a single program directly as a subprocess — no shell parsing. The command and each argument are passed literally; pipes, redirects, globs, environment variables, and command chaining are NOT supported. Use exec ONLY when you are certain the program exists and needs no shell features (e.g. `cargo build`, `python3 script.py`). For anything else — or when in doubt — use the `sh` tool (or `nushell`/`fish`) instead; sh is the default choice for running commands. The `command` is resolved against PATH; absolute paths and paths relative to the working directory are also accepted."
    }

    fn supports_streaming_output() -> bool {
        true
    }

    fn describe_invocation(&self, args: &Self::Args) -> String {
        let full_cmd: Vec<&str> = std::iter::once(&args.command)
            .chain(args.args.iter())
            .map(|s| s.as_str())
            .collect();
        let mut parts = vec![format!("Running command: `{}`.", full_cmd.join(" "))];
        if let Some(ref wd) = args.workdir {
            parts.push(format!(" Working directory: `{}`.", wd));
        }
        if let Some(timeout) = args.timeout {
            parts.push(format!(" Timeout: {}ms.", timeout));
        }
        parts.concat()
    }

    fn execute(
        &self,
        args: Self::Args,
        _x_credentials: Option<&ServiceCredential>,
        working_dir: Option<&Path>,
        _ctx: Option<&ToolContext>,
    ) -> Result<Self::Return, Self::Error> {
        // Guard the model-facing path (not the low-level execute_exec_tool,
        // which the integration tests exercise directly for the subprocess
        // plumbing): reject shell syntax and unknown programs with actionable
        // errors before anything is spawned.
        validate_exec_invocation(&args, working_dir)?;
        execute_exec_tool(&args, working_dir)
    }

    fn execute_streaming(
        &self,
        args: Self::Args,
        _x_credentials: Option<&ServiceCredential>,
        working_dir: Option<&Path>,
        output_tx: crossbeam_channel::Sender<Vec<u8>>,
        _ctx: Option<&ToolContext>,
    ) -> Result<Self::Return, Self::Error> {
        validate_exec_invocation(&args, working_dir)?;
        let program = &args.command;
        let prog_args = &args.args;
        let timeout_ms = args.timeout.unwrap_or(30000);
        let resolved = resolve_workdir(args.workdir.as_deref(), working_dir);

        let mut cmd = std::process::Command::new(program);
        cmd.args(prog_args)
            .current_dir(&resolved)
            .stdout(std::process::Stdio::piped())
            .stderr(std::process::Stdio::piped());
        let display_cmd = if prog_args.is_empty() {
            program.to_string()
        } else {
            format!("{} {}", program, prog_args.join(" "))
        };

        run_shell_streaming(&mut cmd, &display_cmd, timeout_ms, output_tx)
    }

    fn return_string(ret: &Self::Return) -> String {
        ret.clone()
    }
}

/// Shell metacharacters that `exec` never interprets. Their presence in the
/// command or its arguments almost always means the caller wanted a shell
/// (pipes, redirects, globs, env expansion, command chaining) rather than
/// direct program execution. Rejecting up front — with a pointer to the shell
/// tools — lets the model learn the boundary from a single inline error
/// instead of a cryptic "No such file or directory" from the spawned process.
const SHELL_METACHARS: &[char] = &['|', '>', '<', '&', ';', '$', '`', '*', '?', '"', '\''];

/// Reject `command`/`args` containing shell metacharacters.
///
/// The command is checked first: a program name containing shell operators is
/// never legitimate (e.g. the model pasting an entire pipeline into
/// `command`). Args are checked per-argument so the error can name the exact
/// offending value. Both messages direct the caller to `sh`/`nushell`/`fish`,
/// which are the correct tools for shell features.
fn validate_no_shell_syntax(command: &str, args: &[String]) -> Result<(), ToolExecError> {
    if let Some(c) = command.chars().find(|c| SHELL_METACHARS.contains(c)) {
        return Err(ToolExecError(format!(
            "exec does not interpret shell syntax: found '{c}' in command \"{command}\". \
             Pipes, redirects, globs, environment variables, and command chaining need a \
             shell — use the `sh`, `nushell`, or `fish` tool instead."
        )));
    }
    for arg in args {
        if let Some(c) = arg.chars().find(|c| SHELL_METACHARS.contains(c)) {
            return Err(ToolExecError(format!(
                "exec does not interpret shell syntax: found '{c}' in argument \"{arg}\". \
                 Pipes, redirects, globs, environment variables, and command chaining need a \
                 shell — use the `sh`, `nushell`, or `fish` tool instead."
            )));
        }
    }
    Ok(())
}

/// Check whether `path` names an existing, executable regular file.
///
/// On Unix, "executable" means at least one execute bit is set — the same
/// criterion `execvp` effectively uses. On non-Unix platforms we fall back to
/// "is a file" since the executable-bit concept differs.
fn is_executable_file(path: &Path) -> bool {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;
        std::fs::metadata(path)
            .map(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
            .unwrap_or(false)
    }
    #[cfg(not(unix))]
    {
        path.is_file()
    }
}

/// Resolve `command` to an executable file the way the spawned process will.
///
/// Names containing a path separator are used directly — absolute, or relative
/// to `workdir` (the child resolves relative program paths against its current
/// directory). Bare names are searched on `PATH`. Returns `None` when nothing
/// executable was found, mirroring what `Command::new` + `execvp` would fail
/// with.
fn resolve_program(command: &str, workdir: &Path) -> Option<PathBuf> {
    if command.contains('/') {
        // Absolute or workdir-relative path — check it as-is. Command::new
        // passes the string straight to execvp, which resolves relative
        // program paths against the child's current directory (workdir).
        let candidate = if Path::new(command).is_absolute() {
            PathBuf::from(command)
        } else {
            workdir.join(command)
        };
        return is_executable_file(&candidate).then_some(candidate);
    }
    std::env::var_os("PATH").and_then(|path| {
        std::env::split_paths(&path)
            .map(|dir| dir.join(command))
            .find(|p| is_executable_file(p))
    })
}

/// Pre-flight validation for the model-facing `exec` tool.
///
/// Combines the shell-syntax guard and the program-existence check into one
/// call the `Tool` impls share, so `execute` and `execute_streaming` never
/// drift. Pure filesystem introspection — nothing is spawned — so it is safe
/// to call before the subprocess machinery starts.
fn validate_exec_invocation(
    args: &ExecArgs,
    working_dir: Option<&Path>,
) -> Result<(), ToolExecError> {
    validate_no_shell_syntax(&args.command, &args.args)?;
    let workdir = resolve_workdir(args.workdir.as_deref(), working_dir);
    if resolve_program(&args.command, &workdir).is_none() {
        // Surface the searched PATH so the model can see exactly what was
        // tried, and give it two concrete recovery paths.
        let path_list = std::env::var_os("PATH")
            .map(|p| p.to_string_lossy().into_owned())
            .unwrap_or_else(|| "<unset>".to_string());
        return Err(ToolExecError(format!(
            "command not found: '{}'. Searched PATH: {path_list}. Verify the program is \
             installed, pass an absolute path, or use `sh` with `command -v <name>` to \
             discover where it lives.",
            args.command
        )));
    }
    Ok(())
}

pub fn execute_exec_tool(
    args: &ExecArgs,
    working_dir: Option<&Path>,
) -> Result<String, ToolExecError> {
    let program = &args.command;
    let prog_args = &args.args;
    let timeout_ms = args.timeout.unwrap_or(30000);

    let resolved = resolve_workdir(args.workdir.as_deref(), working_dir);

    let mut cmd = std::process::Command::new(program);
    cmd.args(prog_args)
        .current_dir(&resolved)
        .stdout(std::process::Stdio::piped())
        .stderr(std::process::Stdio::piped());

    let (output, was_killed) = spawn_with_watchdog(&mut cmd, timeout_ms)?;

    // Build a display string like "$ program arg1 arg2"
    let display_cmd = if prog_args.is_empty() {
        program.to_string()
    } else {
        format!("{} {}", program, prog_args.join(" "))
    };

    Ok(format_shell_output(
        &display_cmd,
        &output,
        timeout_ms,
        was_killed,
    ))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::tools::Tool;

    #[test]
    fn exec_tool_has_valid_metadata() {
        let tool = super::Exec;
        assert!(!tool.name().is_empty());
        assert!(!tool.description().is_empty());
        let schema = tool.schema();
        assert!(schema.is_object());
    }

    #[test]
    fn describe_invocation_includes_command_and_args() {
        let tool = Exec;
        let args = ExecArgs {
            command: "cargo".into(),
            args: vec!["build".into(), "--release".into()],
            workdir: None,
            timeout: None,
        };
        let desc = tool.describe_invocation(&args);
        assert_eq!(desc, "Running command: `cargo build --release`.");
    }

    #[test]
    fn describe_invocation_includes_workdir() {
        let tool = Exec;
        let args = ExecArgs {
            command: "make".into(),
            args: vec![],
            workdir: Some("/home/user/project".into()),
            timeout: None,
        };
        let desc = tool.describe_invocation(&args);
        assert!(desc.contains("Running command: `make`."));
        assert!(desc.contains("Working directory: `/home/user/project`."));
    }

    #[test]
    fn describe_invocation_includes_timeout() {
        let tool = Exec;
        let args = ExecArgs {
            command: "sleep".into(),
            args: vec!["10".into()],
            workdir: None,
            timeout: Some(30000),
        };
        let desc = tool.describe_invocation(&args);
        assert!(desc.contains("Running command: `sleep 10`."));
        assert!(desc.contains("Timeout: 30000ms."));
    }

    // ── Shell-syntax guard ────────────────────────────────────────────

    #[test]
    fn shell_syntax_rejected_in_command() {
        // The classic failure: the model pastes a whole pipeline into `command`.
        let err = validate_no_shell_syntax("echo hello | grep world", &[])
            .unwrap_err()
            .to_string();
        assert!(err.contains("shell syntax"), "{err}");
        assert!(err.contains("`sh`"), "{err}");
    }

    #[test]
    fn shell_syntax_rejected_in_args() {
        // Redirects and chaining in arguments are also shell intent.
        let err = validate_no_shell_syntax("foo", &["a > out.txt".into()])
            .unwrap_err()
            .to_string();
        assert!(err.contains("shell syntax"), "{err}");
        assert!(err.contains("`sh`"), "{err}");
    }

    #[test]
    fn shell_syntax_rejects_globs_and_quotes() {
        for bad in ["*.rs", "\"quoted\"", "a$VAR", "a'b'"] {
            let res = validate_no_shell_syntax("cmd", &[bad.into()]);
            assert!(res.is_err(), "expected '{bad}' to be rejected");
        }
    }

    #[test]
    fn clean_invocation_accepted() {
        assert!(validate_no_shell_syntax("cargo", &["build".into(), "--release".into()]).is_ok());
        assert!(validate_no_shell_syntax("git", &["log".into(), "--oneline".into()]).is_ok());
    }

    // ── Program-existence guard ───────────────────────────────────────

    #[test]
    fn unknown_program_rejected_with_actionable_error() {
        let args = ExecArgs {
            command: "definitely-not-a-real-binary-xyz".into(),
            args: vec![],
            workdir: None,
            timeout: None,
        };
        let err = validate_exec_invocation(&args, None)
            .unwrap_err()
            .to_string();
        assert!(err.contains("command not found"), "{err}");
        assert!(err.contains("command -v"), "{err}");
        assert!(err.contains("PATH"), "{err}");
    }

    #[test]
    fn known_program_accepted() {
        // Existence check only — nothing is spawned in this unit test.
        let args = ExecArgs {
            command: "sh".into(),
            args: vec![],
            workdir: None,
            timeout: None,
        };
        assert!(validate_exec_invocation(&args, None).is_ok());
    }

    #[test]
    fn absolute_path_accepted() {
        // /bin/sh must exist and be executable on any Unix dev box.
        let args = ExecArgs {
            command: "/bin/sh".into(),
            args: vec![],
            workdir: None,
            timeout: None,
        };
        assert!(validate_exec_invocation(&args, None).is_ok());
    }
}