trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
//! 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),
    }
}

/// The line typed into the control-plane backend's pane: [`build_claude_command`]
/// behind an `env` carrying the config-decided renderer (#8405).
///
/// Why: this pane is a tmux pane like any other managed one, so without the
/// assignment the tmux server's inherited `CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN`
/// decided the renderer here too.
/// What: `env <env_unset_flags> <configured_shell_assignments>
/// <build_claude_command>`, with the renderer
/// [`crate::core::alt_screen::configured_alternate_screen_in`] reads from
/// `config_root`. The operand values are `0`/`1` or the pinned
/// `${NAME-default}` form, so nothing caller-supplied enters the prefix.
/// Test: `pane_claude_line_follows_the_configured_renderer`,
/// `pane_claude_line_never_passes_on_the_profile_stamp`.
pub(crate) fn pane_claude_line(
    config_root: Option<&std::path::Path>,
    claude_cmd: &str,
    prompt_file: Option<&std::path::Path>,
) -> Result<String> {
    let assignments = crate::core::alt_screen::configured_shell_assignments(
        crate::core::alt_screen::configured_alternate_screen_in(config_root),
    );
    // #8453: the shared scrub, so a supervisor's profile stamp in the tmux
    // server's environment never reaches this child; the `-u` flags must
    // precede every assignment (POSIX `env`).
    Ok(format!(
        "env{} {assignments} {}",
        crate::core::claude_env_scrub::env_unset_flags(),
        build_claude_command(claude_cmd, prompt_file)?
    ))
}

/// 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`.
        // #8405: the operator's config decides the renderer.
        let cmd_str = pane_claude_line(
            crate::core::alt_screen::operator_config_root().as_deref(),
            &claude_cmd,
            prompt_file.as_deref(),
        )
        .with_context(|| format!("building claude command for session {session_id}"))?;
        let target = TmuxTarget::session(&tmux_name);
        // #8233 review round 2 (finding 5): `send_line` types straight into the
        // pane's canonical-mode tty with no length guard, so a long `claude_cmd`
        // or prompt-file path silently lost its tail here exactly as it did on
        // the managed path. `send_command_line` refuses an over-length line
        // instead of letting the kernel truncate it.
        driver
            .send_command_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");
    }

    /// `TmuxBackend::new` returns an error, never a panic, when tmux cannot
    /// run; with a working tmux it spawns its session and `Drop` kills it.
    ///
    /// #6542: this used to call `new` against whatever tmux the host resolved,
    /// so under `--include-ignored` on a host WITH tmux it spawned `tm_proj_0`
    /// running the real `claude` on the operator's server. Every tmux call now
    /// resolves through a `with_tmux_binary` scope: a missing binary for the
    /// error path, a `PrivateTmuxServer` shim for the spawn path, which is
    /// skipped when tmux is absent. The server dies with the guard, panic
    /// included.
    #[test]
    #[ignore = "spawns a session on a private tmux server when tmux is on PATH"]
    #[serial_test::serial]
    fn tmux_backend_constructs_without_spawning() {
        use crate::core::tmux::with_tmux_binary;
        use crate::test_support::tmux_session::{PrivateTmuxServer, ScratchTmuxSession};

        let rt = tokio::runtime::Builder::new_current_thread()
            .build()
            .expect("runtime");
        let new_backend = || {
            TmuxBackend::new(
                ControlSessionId::new("proj", 0),
                "/tmp",
                None,
                "true".into(),
                100,
            )
        };

        let missing = std::path::PathBuf::from("/nonexistent/tmux-6542");
        let result = rt.block_on(with_tmux_binary(missing, async { new_backend() }));
        assert!(result.is_err(), "an unusable tmux must be an Err");

        let tmux_bin = crate::core::tmux::resolve_tmux_binary_or_bare();
        if !ScratchTmuxSession::tmux_available(&tmux_bin) {
            eprintln!("tmux not available; skipping the spawn half");
            return;
        }
        let server = PrivateTmuxServer::new(&tmux_bin, "backend");
        let shim = std::path::PathBuf::from(server.shim_bin());
        // tmux stores `tm:proj:0` as `tm_proj_0`: `:` is not legal in a name.
        let live = || server.query(&["has-session", "-t", "=tm_proj_0"]).is_some();
        rt.block_on(with_tmux_binary(shim, async {
            let backend = new_backend().expect("new spawns on the private server");
            assert!(live(), "the session must exist on the private server");
            drop(backend);
            assert!(!live(), "Drop must kill the session it spawned (#1452)");
        }));
    }

    /// 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);
    }

    /// #8405: the backend's pane line carries the renderer its config root
    /// decides, both directions, ahead of the still-quoted command.
    #[test]
    fn pane_claude_line_follows_the_configured_renderer() {
        for (alternate_screen, want) in [
            (true, " CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=0 "),
            (false, " CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1 "),
        ] {
            let root = tempfile::tempdir().expect("tempdir");
            std::fs::write(
                root.path().join("config.yaml"),
                format!("tmux:\n  alternate_screen: {alternate_screen}\n"),
            )
            .expect("write config");
            let line =
                pane_claude_line(Some(root.path()), "claude; rm -rf /", None).expect("quotable");
            assert!(line.starts_with("env "), "an `env` prefix: {line}");
            assert!(line.contains(want), "want {want:?}: {line}");
            assert!(line.ends_with("'claude; rm -rf /'"), "still quoted: {line}");
        }
    }

    /// #8453: the pane inherits the tmux server's environment, which carries a
    /// supervisor's `TRUSTY_MPM_SESSION_PROFILE` when that session started the
    /// server. The line must unset it so the child has no launch stamp of its
    /// own and the guard treats it as a PM. The name is hard-coded so an
    /// emptied scrub list cannot make this pass.
    #[test]
    fn pane_claude_line_never_passes_on_the_profile_stamp() {
        let line = pane_claude_line(None, "claude", None).expect("quotable");
        let unset = crate::core::claude_env_scrub::parse_env_unset_vars(&line);
        assert!(
            unset.contains(&"TRUSTY_MPM_SESSION_PROFILE"),
            "the control-plane pane line must unset the profile stamp: {line}"
        );
    }
}