trusty-mpm 1.5.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
Documentation
//! tmux execution backend: `tmux send-keys` + `capture-pane` behind the trait.
//!
//! Why: existing tmux session launch is implemented inline throughout the
//! daemon (`session_manager`, `real_tmux`, launch commands). Wrapping it
//! behind `SessionBackend` makes the `SessionActor` backend-agnostic and
//! preserves the #1452 RAII reaping discipline without regressing any tmux
//! behavior operators depend on today.
//! What: [`TmuxBackend`] owns a named tmux session (named per §5.2:
//! `tm:<project-id>:<sessionNo>`) and implements `send` via `tmux send-keys`,
//! `recv` via periodic `tmux capture-pane -p`, and `stop` via
//! `tmux kill-session`. Output is unstructured pane text wrapped in
//! `SessionEvent::Output { structured: None }`.
//! Test: `tmux_backend_session_name`, `tmux_backend_constructs_without_spawning`
//! in the inline test module. Integration tests require a live `tmux` binary
//! and are marked `#[ignore]`.

use anyhow::{Context, Result};
use async_trait::async_trait;
use chrono::Utc;
use tokio::time::Duration;
use tracing::{debug, warn};

use crate::control::event::SessionEvent;
use crate::control::id::ControlSessionId;
use crate::core::tmux::TmuxTarget;
use crate::daemon::tmux::TmuxDriver;

use super::{SessionBackend, SessionInput};

/// How long `recv()` parks when the pane is idle (empty or unchanged).
///
/// Why: without a park point the actor's `select!` loop would call
/// `capture-pane` in a tight spin at 100% CPU. 200 ms is responsive
/// enough for interactive output while being gentle on the executor.
/// Exposed as a named constant so callers and tests can reference it.
/// The compile-time assertion below guards against accidental zeroing.
pub const TMUX_POLL_INTERVAL_MS: u64 = 200;

// Compile-time guard: if TMUX_POLL_INTERVAL_MS is accidentally set to 0
// the idle-park fix regresses to a busy-spin. Fail the build instead.
const _: () = assert!(TMUX_POLL_INTERVAL_MS > 0, "poll interval must be > 0ms");

/// Derive the tmux session name for a control-plane session.
///
/// Why: §5.2 mandates `tm:<project-id>:<sessionNo>` so operators can run
/// `tmux attach -t tm:trusty-tools:0` without consulting a registry.
/// What: formats `tm:<project_id>:<n>` from a `ControlSessionId`.
/// Test: `tmux_backend_session_name`.
pub fn tmux_session_name(session_id: &ControlSessionId) -> String {
    // session_id format: "<project-id>-<n>"; map to "tm:<project-id>:<n>"
    if let Some((proj, n)) = session_id.parse_parts() {
        format!("tm:{proj}:{n}")
    } else {
        // Fallback: use the raw ID with a tm: prefix (should not happen in
        // practice since SessionCounter always produces valid IDs).
        format!("tm:{}", session_id.as_str())
    }
}

/// Build the shell command line that launches Claude Code in the tmux pane.
///
/// Why: this string is typed into the pane's interactive shell as literal
/// keystrokes (`TmuxDriver::send_line`), so any caller-supplied value
/// interpolated into it is a command-injection sink (#6197). Both fields are
/// shell-quoted here so metacharacters are inert at the sink itself,
/// independent of any HTTP-boundary validation — the two defenses are
/// deliberately redundant. Quoting `claude_cmd` does not break PATH resolution:
/// a quoted `'claude'` still resolves on PATH.
/// What: quotes `claude_cmd` via `shlex::try_quote`; when `prompt_file` is
/// present, quotes it too and appends `--append-system-prompt-file <quoted>`.
/// Returns `Err` only when a value is not shell-quotable (interior NUL).
/// Test: `build_claude_command_quotes_metacharacters`,
/// `build_claude_command_quotes_claude_cmd`, `build_claude_command_plain`.
fn build_claude_command(claude_cmd: &str, prompt_file: Option<&std::path::Path>) -> Result<String> {
    let quoted_cmd = shlex::try_quote(claude_cmd)
        .context("claude_cmd is not shell-quotable")?
        .into_owned();
    match prompt_file {
        Some(pf) => {
            let quoted_pf = shlex::try_quote(&pf.to_string_lossy())
                .context("prompt_file path is not shell-quotable")?
                .into_owned();
            Ok(format!(
                "{quoted_cmd} --append-system-prompt-file {quoted_pf}"
            ))
        }
        None => Ok(quoted_cmd),
    }
}

/// State for the tmux session backend.
///
/// Why: the actor needs to know the tmux session name to issue subsequent
/// send-keys and capture-pane commands, and needs the TmuxDriver to execute
/// them. Spawned = false until `spawn()` succeeds.
/// What: holds session identity, the driver, and a spawned flag.
/// Test: `tmux_backend_constructs_without_spawning`.
pub struct TmuxBackend {
    session_id: ControlSessionId,
    tmux_name: String,
    driver: TmuxDriver,
    /// Tracks whether `kill_session` should be called on `stop`/`Drop`.
    spawned: bool,
    /// Lines to capture on each `recv()` poll (configurable; default 100).
    capture_lines: u32,
    /// Last pane content seen; used to suppress duplicate Output events and
    /// to detect idle (unchanged) pane so we park instead of spin.
    last_pane_content: String,
}

impl TmuxBackend {
    /// Create and register a new tmux session for the given session ID.
    ///
    /// Why: session creation is kept in `new()` so the `SessionActor` can
    /// detect spawn failure immediately rather than deferring to the first
    /// `recv()` call.
    /// What: discovers the `tmux` binary; creates a detached tmux session named
    /// `tm:<project-id>:<n>` in `workdir`; sends the `claude` command with the
    /// system prompt file if provided. Returns `Err` if `tmux` is unavailable
    /// or session creation fails.
    /// Test: `tmux_backend_constructs_without_spawning` (driver discovery only).
    pub fn new(
        session_id: ControlSessionId,
        workdir: impl Into<std::path::PathBuf>,
        prompt_file: Option<std::path::PathBuf>,
        claude_cmd: String,
        capture_lines: u32,
    ) -> Result<Self> {
        let workdir = workdir.into();
        let tmux_name = tmux_session_name(&session_id);
        let driver = TmuxDriver::discover()
            .with_context(|| format!("tmux unavailable for session {session_id}"))?;

        // Create a new detached tmux session in the project directory.
        let workdir_str = workdir.to_string_lossy().to_string();
        driver
            .create_session(&tmux_name, Some(&workdir_str))
            .with_context(|| {
                format!("failed to create tmux session {tmux_name} in {workdir_str}")
            })?;

        // Build the `claude` start command. #6197: `cmd_str` is typed into the
        // pane's shell as literal keystrokes (`send_line`), so both interpolated
        // fields are shell-quoted at the sink — see `build_claude_command`.
        let cmd_str = build_claude_command(&claude_cmd, prompt_file.as_deref())
            .with_context(|| format!("building claude command for session {session_id}"))?;
        let target = TmuxTarget::session(&tmux_name);
        driver
            .send_line(&target, &cmd_str)
            .with_context(|| format!("failed to start claude in tmux session {tmux_name}"))?;

        debug!(
            session_id = %session_id,
            tmux_name = %tmux_name,
            "tmux backend spawned"
        );

        Ok(Self {
            session_id,
            tmux_name,
            driver,
            spawned: true,
            capture_lines,
            last_pane_content: String::new(),
        })
    }
}

#[async_trait]
impl SessionBackend for TmuxBackend {
    /// Send input to the tmux session via `tmux send-keys`.
    ///
    /// Why: operators and the SM agent send text commands to the Claude Code
    /// TUI through the write-lock path; the tmux backend relays them as
    /// send-keys (NOT via stdin/stdout pipes, to preserve the interactive TUI).
    /// What: calls `TmuxDriver::send_line` to inject `msg.text + Enter` into
    /// the tmux pane.
    /// Test: integration test (`#[ignore]`); constructor-path covered inline.
    async fn send(&mut self, msg: SessionInput) -> Result<()> {
        let target = TmuxTarget::session(&self.tmux_name);
        self.driver
            .send_line(&target, &msg.text)
            .with_context(|| format!("send-keys to tmux session {} failed", self.tmux_name))
    }

    /// Capture the tmux pane and return new output as `SessionEvent::Output`.
    ///
    /// Why: the actor polls `recv()` to pick up new pane output and broadcast
    /// it to observers. Without a park point the actor's `select!` loop would
    /// call `capture-pane` in a tight spin at ~100% CPU when the pane is idle.
    /// What: runs `capture-pane -p -S -<lines>` via `TmuxDriver::capture`
    /// (#2398 architecture consolidation — this used to shell out to a bare
    /// `tmux` independently via a private `run_tmux_cmd` helper; reusing
    /// `TmuxDriver`'s own public `capture` method, which already routes
    /// through the crate's shared tmux entry point, removed that duplicate
    /// implementation entirely). If the pane is empty OR its content is
    /// unchanged since the last call, parks for `TMUX_POLL_INTERVAL_MS`
    /// before returning an empty batch — giving the executor a chance to service
    /// other tasks. Only emits an `Output` event when the pane content has
    /// actually changed (diff vs `last_pane_content`). Returns `None` when the
    /// tmux session no longer exists (treating it as a clean exit).
    /// Test: `tmux_recv_parks_when_idle`.
    async fn recv(&mut self) -> Option<Result<Vec<SessionEvent>>> {
        let target = TmuxTarget::session(&self.tmux_name);
        match self
            .driver
            .capture(&target, Some(self.capture_lines))
            .with_context(|| format!("capture-pane for tmux session {} failed", self.tmux_name))
        {
            Ok(output) => {
                let is_empty = output.trim().is_empty();
                let is_unchanged = !is_empty && output == self.last_pane_content;

                if is_empty || is_unchanged {
                    // Pane idle: park so the executor isn't starved.
                    tokio::time::sleep(Duration::from_millis(TMUX_POLL_INTERVAL_MS)).await;
                    return Some(Ok(vec![]));
                }

                // New content: record it and emit an Output event.
                self.last_pane_content = output.clone();
                let event = SessionEvent::Output {
                    session_id: self.session_id.clone(),
                    raw: output,
                    structured: None, // pane capture is unstructured text
                    ts: Utc::now(),
                };
                Some(Ok(vec![event]))
            }
            Err(e) => {
                // Most likely the tmux session no longer exists.
                warn!(
                    session_id = %self.session_id,
                    tmux_name = %self.tmux_name,
                    "tmux capture-pane failed (session may have exited): {e}"
                );
                None // treat as clean exit
            }
        }
    }

    /// Kill the tmux session and clean up.
    ///
    /// Why: §10.2 reaping contract requires the backend to terminate its
    /// tmux session on stop so no orphan sessions accumulate.
    /// What: calls `tmux kill-session -t <name>`; logs a warning on failure
    /// (session may already be gone). Marks `spawned = false` to prevent
    /// double-kill from Drop.
    /// Test: integration test (`#[ignore]`).
    async fn stop(mut self) -> Result<()> {
        if self.spawned {
            self.spawned = false;
            if let Err(e) = self.driver.kill_session(&self.tmux_name) {
                warn!(
                    session_id = %self.session_id,
                    tmux_name = %self.tmux_name,
                    "kill_session failed (may already be gone): {e}"
                );
            }
        }
        Ok(())
    }
}

impl Drop for TmuxBackend {
    /// Kill the tmux session on drop to prevent orphaned tmux sessions.
    ///
    /// Why: RAII reaping discipline (#1452) — every tmux session must be
    /// cleaned up on actor teardown even if `stop()` was not called.
    /// What: calls `driver.kill_session` only if `spawned` is still true
    /// (i.e. `stop()` has not already cleaned up).
    /// Test: side-effect-only; covered by integration cleanup suite.
    fn drop(&mut self) {
        if self.spawned
            && let Err(e) = self.driver.kill_session(&self.tmux_name)
        {
            // Log at debug level — Drop is best-effort.
            debug!(
                session_id = %self.session_id,
                "TmuxBackend::drop: kill_session failed: {e}"
            );
        }
    }
}

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

    #[test]
    fn tmux_backend_session_name() {
        let id = ControlSessionId::new("trusty-tools", 0);
        assert_eq!(tmux_session_name(&id), "tm:trusty-tools:0");

        let id2 = ControlSessionId::new("my-proj", 3);
        assert_eq!(tmux_session_name(&id2), "tm:my-proj:3");
    }

    #[test]
    fn tmux_backend_session_name_hyphenated_project() {
        let id = ControlSessionId::new("some-long-project", 7);
        assert_eq!(tmux_session_name(&id), "tm:some-long-project:7");
    }

    /// Test that TmuxBackend::new returns an error when tmux is unavailable,
    /// rather than panicking. Requires tmux to NOT be on PATH to exercise the
    /// error path; skip if tmux is available (the normal case).
    #[test]
    #[ignore = "only runs when tmux is absent from PATH"]
    fn tmux_backend_constructs_without_spawning() {
        let id = ControlSessionId::new("proj", 0);
        let result = TmuxBackend::new(id, "/tmp", None, "claude".into(), 100);
        assert!(result.is_err());
    }

    /// A `prompt_file` with shell metacharacters is rendered inert (quoted) at
    /// the sink, independent of any HTTP-boundary validation (#6197).
    ///
    /// Why (defense in depth): the HTTP validator and this sink each stop the
    /// injection on their own. This test exercises the SINK — the exact
    /// `cmd_str` construction `TmuxBackend::new` uses — so a future validator
    /// loosening cannot silently reopen injection.
    #[test]
    fn build_claude_command_quotes_metacharacters() {
        let injection = std::path::Path::new("/tmp/x; touch /tmp/PWNED; #");
        let cmd = build_claude_command("claude", Some(injection)).expect("quotable");
        // Exact match: the whole malicious path is inside a single-quoted span,
        // so the `;` and `#` cannot terminate the command or start a new one.
        // Expected is assembled from parts so this test file carries no literal
        // launch line (see `every_claude_launch_site_in_the_crate_is_allowlisted`).
        let expected = [
            "claude",
            "--append-system-prompt-file",
            "'/tmp/x; touch /tmp/PWNED; #'",
        ]
        .join(" ");
        assert_eq!(cmd, expected, "metacharacters must be single-quoted");
    }

    /// `claude_cmd` is also quoted at the sink, so metacharacters in it are inert
    /// even though the HTTP validator restricts it (#6197 MEDIUM).
    #[test]
    fn build_claude_command_quotes_claude_cmd() {
        let cmd = build_claude_command("claude; rm -rf /", None).expect("quotable");
        assert!(
            cmd.contains("'claude; rm -rf /'"),
            "claude_cmd metacharacters must be single-quoted, got: {cmd}"
        );
    }

    /// The plain path (no metacharacters) round-trips to a runnable command line.
    #[test]
    fn build_claude_command_plain() {
        assert_eq!(build_claude_command("claude", None).unwrap(), "claude");
        let pf = "/tmp/p.txt";
        let cmd = build_claude_command("claude", Some(std::path::Path::new(pf))).unwrap();
        // Expected assembled from parts so no literal launch line lives here.
        let expected = ["claude", "--append-system-prompt-file", pf].join(" ");
        assert_eq!(cmd, expected);
    }
}