agentmux_srv/server/shell_handlers.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4#[cfg(windows)]
5use agentmux_common::win32::NoWindow;
6use std::sync::Arc;
7
8use crate::backend::rpc::engine::WshRpcEngine;
9use crate::backend::rpc_types::{
10 COMMAND_SHELL_EXEC, COMMAND_SHELL_STOP, COMMAND_SHELL_STATUS,
11 CommandShellExecData, ShellExecResult, CommandShellStopData, CommandShellStatusData,
12 ShellStopResult, ShellStatusResult,
13};
14use crate::backend::base::{expand_home_dir_safe, msys_to_windows_path};
15
16use super::AppState;
17
18pub fn register_shell_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
19 // shellstop → tree-kill a running persistent shell node (Phase 3). Invoked
20 // by the UI stop button on a running PersistentShellBlock. The runner then
21 // publishes a `stopped` exit event.
22 let shell_sessions_stop = state.shell_sessions.clone();
23 engine.register_typed(
24 COMMAND_SHELL_STOP,
25 move |req: CommandShellStopData, _ctx| {
26 let registry = shell_sessions_stop.clone();
27 async move {
28 let stopped = registry.stop(&req.shell_id);
29 tracing::info!(shell_id = %req.shell_id, stopped, "shellstop");
30 Ok(ShellStopResult { stopped })
31 }
32 },
33 );
34
35 // shellstatus → query a shell's current running state. Used by
36 // useShellNodeStream to resolve the TRUE status of a shell whose
37 // `shell_node_create` event is being replayed on pane mount/reconnect
38 // (persist:64 ring), instead of assuming "running" — the frontend can't
39 // otherwise tell a live spawn apart from a replay of an already-long-
40 // exited shell, which was flashing stale rows in the Activity Dock.
41 // See docs/retro/retro-activity-dock-stale-shell-flash-on-load-2026-08-22.md.
42 //
43 // Reports `known: false` (rather than collapsing into `running: false`,
44 // like the MCP-facing HTTP route does) when no registry entry exists yet
45 // — the shell may simply not have finished spawning/registering (see
46 // `get_status_if_known`'s doc comment for the exact race this closes:
47 // reagent P1 on PR #2770, a genuinely live shell misreported as already
48 // exited). The frontend treats `known: false` as "don't correct."
49 let shell_sessions_status = state.shell_sessions.clone();
50 engine.register_typed(
51 COMMAND_SHELL_STATUS,
52 move |req: CommandShellStatusData, _ctx| {
53 let registry = shell_sessions_status.clone();
54 async move {
55 Ok(match registry.get_status_if_known(&req.shell_id) {
56 Some(s) => ShellStatusResult {
57 known: true,
58 running: s.running,
59 exit_code: s.exit_code,
60 line_count: s.line_count,
61 },
62 // `known: false` with running/exit_code/line_count zeroed.
63 // Not `Default::default()`: the zeros here are filler for
64 // "we have nothing to say", and spelling them out keeps
65 // that distinct from a shell that genuinely exited 0.
66 None => ShellStatusResult {
67 known: false,
68 running: false,
69 exit_code: None,
70 line_count: 0,
71 },
72 })
73 }
74 },
75 );
76
77 // shellexec → run a shell command and return output.
78 // Invoked by the `!cmd` prefix in the agent pane composer.
79 //
80 // Host agents: sh -c <cmd> in the agent's working directory (all platforms;
81 // Git Bash provides sh on Windows, located by
82 // backend::posix_shell).
83 // Container agents: docker exec <container> sh -c <cmd> via bollard.
84 // The host cmd:cwd is not valid inside the container — the
85 // command runs in the container's own working directory.
86 let mstore_se = state.mstore.clone();
87 let container_manager = state.container_manager.clone();
88 engine.register_typed(
89 COMMAND_SHELL_EXEC,
90 move |cmd: CommandShellExecData, _ctx| {
91 let mstore = mstore_se.clone();
92 let cm_opt = container_manager.clone();
93 async move {
94 // Log block_id at info; keep the command at debug so secrets
95 // passed as CLI args (API tokens, passwords) don't land in
96 // ~/.agentmux/logs/ in plaintext.
97 tracing::info!(block_id = %cmd.blockid, "ShellExec");
98 tracing::debug!(command = %cmd.command, "ShellExec command");
99
100 let block: crate::backend::obj::Block = mstore
101 .get(&cmd.blockid)
102 .map_err(|e| format!("shellexec: load block: {e}"))?
103 .ok_or_else(|| format!("shellexec: block {} not found", cmd.blockid))?;
104 let agent_mode = crate::backend::obj::meta_get_string(
105 &block.meta, "agentMode", "host",
106 );
107
108 // 300s process timeout matches the frontend's RPC timeout so
109 // the client sees a clean error rather than a silent EC-TIME.
110 const TIMEOUT_SECS: u64 = 300;
111 // 1 MB cap per stream — bounds memory for runaway commands
112 // (`! yes`, `! dd if=/dev/zero`).
113 const MAX_OUTPUT: u64 = 1_000_000;
114
115 // Shared output formatter: if we accumulated more than MAX_OUTPUT
116 // bytes, the stream was truncated — append a notice.
117 fn format_output(bytes: Vec<u8>, cap: u64) -> String {
118 if bytes.len() > cap as usize {
119 let s = String::from_utf8_lossy(&bytes[..cap as usize]);
120 format!("{s}…[output capped at {cap} bytes]")
121 } else {
122 String::from_utf8_lossy(&bytes).into_owned()
123 }
124 }
125
126 // ── Container agents ─────────────────────────────────────────
127 if agent_mode == "container" {
128 let cm = cm_opt.get().await.ok_or_else(|| {
129 "shellexec: Docker not available on this host; \
130 cannot exec in container agent".to_string()
131 })?;
132
133 let agent_id = crate::backend::obj::meta_get_string(
134 &block.meta, "agentId", "",
135 );
136 if agent_id.is_empty() {
137 return Err("shellexec: container agent missing agentId in block meta".to_string());
138 }
139 let container_name =
140 crate::backend::container::container_name_for_slug(&agent_id);
141
142 let session = cm.exec(
143 &container_name,
144 &["sh".to_string(), "-c".to_string(), cmd.command.clone()],
145 None, // container's own working directory (host path invalid inside)
146 &[], // no extra env vars for !cmd
147 false, // no stdin — `!cmd` output-only; see ContainerManager::exec
148 ).await.map_err(|e| format!("shellexec: container exec failed: {e}"))?;
149
150 let exec_id = session.exec_id.clone();
151 // No stdin needed for !cmd — drop to signal EOF immediately.
152 drop(session.input);
153
154 let mut stdout_buf: Vec<u8> = Vec::new();
155 let mut stderr_buf: Vec<u8> = Vec::new();
156
157 use futures_util::StreamExt as _;
158 use bollard::container::LogOutput;
159
160 let timeout_result = tokio::time::timeout(
161 std::time::Duration::from_secs(TIMEOUT_SECS),
162 async {
163 let mut output = std::pin::pin!(session.output);
164 while let Some(item) = output.next().await {
165 match item {
166 Err(e) => return Err(format!(
167 "shellexec: container output read: {e}"
168 )),
169 Ok(log) => match log {
170 LogOutput::StdOut { message } => {
171 // Accumulate MAX_OUTPUT+1 bytes (the +1 sentinel lets
172 // format_output distinguish "exactly at cap" from
173 // "truncated", matching the host branch's take logic).
174 let cap = (MAX_OUTPUT + 1) as usize;
175 let take = cap
176 .saturating_sub(stdout_buf.len())
177 .min(message.len());
178 stdout_buf.extend_from_slice(&message[..take]);
179 }
180 LogOutput::StdErr { message } => {
181 let cap = (MAX_OUTPUT + 1) as usize;
182 let take = cap
183 .saturating_sub(stderr_buf.len())
184 .min(message.len());
185 stderr_buf.extend_from_slice(&message[..take]);
186 }
187 _ => {} // StdIn / Console frames not relevant here
188 }
189 }
190 }
191 Ok(())
192 }
193 ).await;
194
195 if timeout_result.is_err() {
196 // Kill the in-container process so it doesn't linger after
197 // timeout. Mirrors the host branch's process-group SIGKILL.
198 // signal_exec_process runs `pkill -KILL -f <pattern>` inside
199 // the container; fire-and-forget (non-match is not an error).
200 //
201 // Pattern is "sh -c <command>" (the full cmdline we spawned),
202 // not just cmd.command — a bare command like "python" or "sh"
203 // would over-match the agent's own running processes.
204 let kill_pattern = format!("sh -c {}", cmd.command);
205 cm.signal_exec_process(&container_name, &kill_pattern, true).await.ok();
206 return Err(format!("shellexec: timed out after {TIMEOUT_SECS}s"));
207 }
208 timeout_result.unwrap()?;
209
210 // The output stream closing does not carry the exit status —
211 // retrieve it separately via inspect_exec.
212 let exit_code = cm.inspect_exec(&exec_id).await
213 .ok()
214 .flatten()
215 .unwrap_or(1) as i32;
216
217 tracing::debug!(
218 block_id = %cmd.blockid,
219 container = %container_name,
220 exit_code,
221 "ShellExec container done",
222 );
223
224 let result = ShellExecResult {
225 exit_code,
226 stdout: format_output(stdout_buf, MAX_OUTPUT),
227 stderr: format_output(stderr_buf, MAX_OUTPUT),
228 };
229 return Ok(result);
230 }
231
232 // ── Host agents ───────────────────────────────────────────────
233 let cwd: Option<std::path::PathBuf> = if cmd.working_dir.is_empty() {
234 None
235 } else {
236 // Convert MSYS/Git-Bash paths like /c/Users/... to C:\Users\...
237 // before expansion. Without this, canonicalize() fails on Windows
238 // with os error 267 (ERROR_DIRECTORY) — same fix applied to the
239 // persistent shell node in d89c1458.
240 let native = msys_to_windows_path(&cmd.working_dir);
241 let expanded = expand_home_dir_safe(&native);
242 // Reject non-absolute paths to prevent relative traversal
243 // (e.g. "../etc") from resolving against the sidecar's cwd.
244 // expand_home_dir_safe turns "~" into an absolute home path;
245 // anything still relative after expansion is suspicious.
246 if !expanded.is_absolute() {
247 return Err(format!(
248 "shellexec: working_dir must be absolute, got: {}",
249 expanded.display()
250 ));
251 }
252 // Canonicalize to resolve all symlinks before handing the
253 // path to the shell. Without this a symlinked working_dir
254 // would silently run the shell in the symlink target
255 // (potentially outside the agent workspace), matching the
256 // symlink-escape protection in writeagentconfig.
257 let canonical = expanded.canonicalize()
258 .map_err(|e| format!(
259 "shellexec: cannot resolve working directory '{}': {e}",
260 expanded.display()
261 ))?;
262 Some(canonical)
263 };
264
265 // Use sh -c on all platforms: agents run in a bash environment
266 // (Git Bash on Windows, sh on Unix) so Unix commands like ls/pwd/grep
267 // work consistently. cmd /C would exit 1 for any non-Windows command.
268 // On Windows `sh` is located rather than assumed to be on PATH —
269 // an Explorer-launched build's PATH has only Git\cmd, which has
270 // no sh.exe. See backend::posix_shell.
271 let shell = crate::backend::posix_shell::resolve_posix_shell()
272 .map_err(|e| format!("shellexec: {e}"))?;
273 let mut proc = {
274 let mut c = tokio::process::Command::new(&shell);
275 c.args(["-c", &cmd.command]);
276 // shellexec runs arbitrary agent-supplied commands and, per
277 // the note below, fires on every MCP Shell tool call — so a
278 // build started this way would otherwise adopt this
279 // instance's channel and data dir. Same class as
280 // shell_node.rs; see backend::pane_env.
281 crate::backend::pane_env::sanitize_process_command(&mut c);
282 // CREATE_NO_WINDOW: console-flash suppression — on Windows
283 // this `sh` is Git Bash (console-subsystem), and srv is
284 // launched windowless, so without the flag every shellexec
285 // pops a console window. This is the highest-frequency
286 // console-flash site (fires on every MCP Shell tool call).
287 // See crates/common/src/cli.rs.
288 #[cfg(windows)]
289 {
290 c.no_window();
291 }
292 c
293 };
294 // stdin MUST NOT be inherited. srv's own stdin is the
295 // launcher's lifeline pipe, with a thread parked in a blocking
296 // read on it (bootstrap::install_shutdown_handlers). Git Bash's
297 // MSYS runtime queries its inherited stdin at startup, and on
298 // Windows that query waits behind srv's pending read — forever,
299 // so every `!cmd` hung until the 300s timeout, `!echo hi`
300 // included. A one-shot command has no input anyway; null gives
301 // `!cat` an EOF instead of a read on srv's lifeline.
302 proc.stdin(std::process::Stdio::null());
303 proc.stdout(std::process::Stdio::piped());
304 proc.stderr(std::process::Stdio::piped());
305 // kill_on_drop: when the timeout fires the Child future is
306 // dropped; without this flag the OS process keeps running
307 // (e.g. `! sleep 1000` would linger indefinitely).
308 proc.kill_on_drop(true);
309 // Unix: put the shell in its own process group so that compound
310 // commands (`! a | b`, `! foo &`) are in the same group and can
311 // all be killed at once on timeout. kill_on_drop only kills the
312 // direct sh child; without process_group(0), grandchildren
313 // get reparented to init and outlive the timeout.
314 #[cfg(unix)]
315 {
316 proc.process_group(0);
317 }
318 if let Some(ref dir) = cwd {
319 proc.current_dir(dir);
320 }
321
322 // Joins the agent's tracker before it runs: a `!cmd` that
323 // backgrounds a child ends with the agent (agent_teardown spec §6.5).
324 let mut child = crate::backend::process_tracker::registry::spawn_tracked(&cmd.blockid, &mut proc, crate::backend::process_tracker::registry::Join::Adopted)
325 .map_err(|e| format!("shellexec: spawn failed ({}): {e}", shell.display()))?;
326
327 // Capture the PID before taking stdout/stderr (id() requires
328 // the Child to still have its stdio handles on some platforms).
329 #[cfg(unix)]
330 let child_pgid = child.id().map(|id| id as libc::pid_t);
331
332 let mut stdout_pipe = child.stdout.take().expect("stdout piped");
333 let mut stderr_pipe = child.stderr.take().expect("stderr piped");
334
335 let mut stdout_buf: Vec<u8> = Vec::new();
336 let mut stderr_buf: Vec<u8> = Vec::new();
337
338 use tokio::io::AsyncReadExt as _;
339 // Each stream's capped-read and drain run in the SAME concurrent
340 // branch. A two-phase approach (cap-read all, then drain all)
341 // deadlocks: when stdout exceeds the cap, its read_to_end returns
342 // but the process is now blocked on write() to a full pipe, so it
343 // never writes to stderr, so stderr's read_to_end never gets EOF.
344 // Both reads block, the drain phase is never reached, and the whole
345 // handler hangs for the full 300 s timeout.
346 //
347 // Solution: read MAX_OUTPUT+1 sentinel bytes per stream (≤ MAX_OUTPUT
348 // → complete; MAX_OUTPUT+1 → truncated), then immediately drain the
349 // remainder. stdout and stderr pipelines run concurrently with each
350 // other, and with child.wait().
351 let timeout_result = tokio::time::timeout(
352 std::time::Duration::from_secs(TIMEOUT_SECS),
353 async {
354 let (sout, serr, wait) = tokio::join!(
355 // stdout branch: cap-read then drain
356 async {
357 let mut take = (&mut stdout_pipe).take(MAX_OUTPUT + 1);
358 take.read_to_end(&mut stdout_buf).await
359 .map_err(|e| format!("shellexec: stdout read: {e}"))?;
360 drop(take); // release &mut borrow so stdout_pipe is usable
361 let mut sink = tokio::io::sink();
362 tokio::io::copy(&mut stdout_pipe, &mut sink).await
363 .map_err(|e| format!("shellexec: drain stdout: {e}"))?;
364 Ok::<(), String>(())
365 },
366 // stderr branch: cap-read then drain
367 async {
368 let mut take = (&mut stderr_pipe).take(MAX_OUTPUT + 1);
369 take.read_to_end(&mut stderr_buf).await
370 .map_err(|e| format!("shellexec: stderr read: {e}"))?;
371 drop(take);
372 let mut sink = tokio::io::sink();
373 tokio::io::copy(&mut stderr_pipe, &mut sink).await
374 .map_err(|e| format!("shellexec: drain stderr: {e}"))?;
375 Ok::<(), String>(())
376 },
377 child.wait(),
378 );
379 sout?;
380 serr?;
381 wait.map_err(|e| format!("shellexec: wait: {e}"))
382 }
383 )
384 .await;
385
386 // On timeout: kill_on_drop kills the direct sh child, but
387 // compound commands (`! a | b`, `! foo &`) fork grandchildren
388 // in the same process group. Kill the whole group so they don't
389 // linger. (Unix only; on Windows the job-object approach is a
390 // separate follow-up since tokio doesn't expose it yet.)
391 #[cfg(unix)]
392 if timeout_result.is_err() {
393 if let Some(pgid) = child_pgid {
394 // SAFETY: kill() is async-signal-safe; negative pid
395 // addresses the process group with id=pgid.
396 unsafe { libc::kill(-pgid, libc::SIGKILL); }
397 }
398 }
399
400 let status = timeout_result
401 .map_err(|_| format!("shellexec: timed out after {TIMEOUT_SECS}s"))??;
402
403 let result = ShellExecResult {
404 exit_code: status.code().unwrap_or(1),
405 stdout: format_output(stdout_buf, MAX_OUTPUT),
406 stderr: format_output(stderr_buf, MAX_OUTPUT),
407 };
408 Ok(result)
409 }
410 },
411 );
412}
413
414#[cfg(test)]
415mod tests {
416 use super::*;
417
418 /// All three shell commands record their request and response types.
419 ///
420 /// `shellstop` and `shellstatus` previously answered inline
421 /// `json!({..})` literals, so the registry would have recorded `Value` for
422 /// both -- which is the shape of drift this migration exists to close.
423 #[tokio::test]
424 async fn register_typed_records_every_shell_command() {
425 let state = crate::server::tests::test_state();
426 let (engine, _rx) = WshRpcEngine::new();
427 register_shell_handlers(&engine, &state);
428 let schema = engine.schema_json();
429 let rows = schema.as_array().unwrap();
430 let find = |cmd: &str| {
431 rows.iter()
432 .find(|r| r["command"] == cmd)
433 .unwrap_or_else(|| panic!("{cmd} missing from the schema"))
434 };
435
436 for (cmd, req, resp) in [
437 (COMMAND_SHELL_EXEC, "CommandShellExecData", "ShellExecResult"),
438 (COMMAND_SHELL_STOP, "CommandShellStopData", "ShellStopResult"),
439 (COMMAND_SHELL_STATUS, "CommandShellStatusData", "ShellStatusResult"),
440 ] {
441 let row = find(cmd);
442 assert_eq!(row["requestName"], req, "{cmd} request");
443 assert_eq!(row["responseName"], resp, "{cmd} response");
444 }
445 }
446
447 /// `exit_code` is always PRESENT and null, never omitted.
448 ///
449 /// The hand-written stub typed it `exit_code?: number`, but both arms of
450 /// the handler write the key unconditionally -- the unknown arm literally
451 /// wrote `"exit_code": null`. The generated type says `number | null`,
452 /// which is the honest shape, and this is the half of that claim TypeScript
453 /// cannot check.
454 ///
455 /// It matters here more than usual: `known: false` must stay
456 /// distinguishable from `running: false` (reagent P1 on #2770 -- collapsing
457 /// them misreported a live, still-registering shell as failed for its
458 /// entire run), and a caller reaching for `exit_code === undefined` as a
459 /// proxy for "unknown" would get the wrong answer either way.
460 #[test]
461 fn shellstatus_always_sends_exit_code_even_when_null() {
462 let unknown = ShellStatusResult {
463 known: false,
464 running: false,
465 exit_code: None,
466 line_count: 0,
467 };
468 let v = serde_json::to_value(&unknown).unwrap();
469 assert!(
470 v.get("exit_code").is_some(),
471 "exit_code must be present-and-null, not omitted: {v}",
472 );
473 assert!(v["exit_code"].is_null());
474 assert_eq!(v["known"], false);
475
476 let exited = ShellStatusResult {
477 known: true,
478 running: false,
479 exit_code: Some(0),
480 line_count: 12,
481 };
482 let v = serde_json::to_value(&exited).unwrap();
483 assert_eq!(v["exit_code"], 0, "a real exit 0 is not the same as unknown");
484 assert_eq!(v["known"], true);
485 }
486
487 use crate::backend::rpc_types::RpcMessage;
488 use crate::backend::shell_node::ShellStatusInfo;
489 use parking_lot::Mutex;
490
491 // Seed a shell status directly (mirrors shell_node.rs's own
492 // register_running_for_block/register_exited test helpers) — avoids
493 // spawning a real child process just to exercise the RPC handler's
494 // JSON shape.
495 fn seed_status(state: &AppState, shell_id: &str, running: bool, exit_code: Option<i32>) {
496 let (tx, _rx) = tokio::sync::oneshot::channel::<()>();
497 let status = Arc::new(Mutex::new(ShellStatusInfo {
498 running,
499 exit_code,
500 line_count: 3,
501 ..Default::default()
502 }));
503 state.shell_sessions.register_full(shell_id.to_string(), tx, None, status);
504 }
505
506 async fn call_shellstatus(state: &AppState, shell_id: &str) -> serde_json::Value {
507 let (engine, mut output_rx) = WshRpcEngine::new();
508 register_shell_handlers(&engine, state);
509 engine.handle_message(RpcMessage {
510 command: "shellstatus".to_string(),
511 reqid: "req-shellstatus".to_string(),
512 data: Some(serde_json::json!({ "shell_id": shell_id })),
513 ..Default::default()
514 });
515 let resp = tokio::time::timeout(std::time::Duration::from_secs(1), output_rx.recv())
516 .await
517 .unwrap()
518 .unwrap();
519 resp.data.unwrap()
520 }
521
522 #[tokio::test]
523 async fn shellstatus_reports_running_shell() {
524 let state = crate::server::tests::test_state();
525 seed_status(&state, "sh-running", true, None);
526 let data = call_shellstatus(&state, "sh-running").await;
527 assert_eq!(data["known"], serde_json::json!(true));
528 assert_eq!(data["running"], serde_json::json!(true));
529 assert_eq!(data["line_count"], serde_json::json!(3));
530 assert!(data.get("exit_code").map_or(true, |v| v.is_null()));
531 }
532
533 #[tokio::test]
534 async fn shellstatus_reports_exited_shell_with_exit_code() {
535 let state = crate::server::tests::test_state();
536 seed_status(&state, "sh-exited", false, Some(0));
537 let data = call_shellstatus(&state, "sh-exited").await;
538 assert_eq!(data["known"], serde_json::json!(true));
539 assert_eq!(data["running"], serde_json::json!(false));
540 assert_eq!(data["exit_code"], serde_json::json!(0));
541 }
542
543 #[tokio::test]
544 async fn shellstatus_reports_exited_err_shell() {
545 let state = crate::server::tests::test_state();
546 seed_status(&state, "sh-failed", false, Some(1));
547 let data = call_shellstatus(&state, "sh-failed").await;
548 assert_eq!(data["known"], serde_json::json!(true));
549 assert_eq!(data["running"], serde_json::json!(false));
550 assert_eq!(data["exit_code"], serde_json::json!(1));
551 }
552
553 // Reagent P1 on PR #2770: a shell with no registry entry at all — either
554 // a genuinely unknown id, OR (the actual race that motivated this)
555 // `shell_node_create` was published but the runner hasn't reached
556 // `register_full` yet (still spawning the child process). Both look
557 // identical to this handler, and BOTH must report `known: false`, not
558 // the same shape as a genuinely exited shell — otherwise a live shell
559 // caught in that race gets misreported as failed for its entire run
560 // (ShellChunkAppend never restores a status once set).
561 #[tokio::test]
562 async fn shellstatus_unknown_id_reports_known_false() {
563 let state = crate::server::tests::test_state();
564 let data = call_shellstatus(&state, "sh-never-existed").await;
565 assert_eq!(data["known"], serde_json::json!(false));
566 assert_eq!(data["running"], serde_json::json!(false));
567 assert!(data.get("exit_code").map_or(true, |v| v.is_null()));
568 }
569}