agentmux_srv\server/shell_handlers.rs
1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::Arc;
5
6use crate::backend::rpc::engine::WshRpcEngine;
7use crate::backend::rpc_types::{
8 COMMAND_SHELL_EXEC, COMMAND_SHELL_STOP,
9 CommandShellExecData, ShellExecResult, CommandShellStopData,
10};
11use crate::backend::base::expand_home_dir_safe;
12
13use super::AppState;
14
15pub fn register_shell_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
16 // shellstop → tree-kill a running persistent shell node (Phase 3). Invoked
17 // by the UI stop button on a running PersistentShellBlock. The runner then
18 // publishes a `stopped` exit event.
19 let shell_sessions_stop = state.shell_sessions.clone();
20 engine.register_handler(
21 COMMAND_SHELL_STOP,
22 Box::new(move |data, _ctx| {
23 let registry = shell_sessions_stop.clone();
24 Box::pin(async move {
25 let req: CommandShellStopData = serde_json::from_value(data)
26 .map_err(|e| format!("shellstop: {e}"))?;
27 let stopped = registry.stop(&req.shell_id);
28 tracing::info!(shell_id = %req.shell_id, stopped, "shellstop");
29 Ok(Some(serde_json::json!({ "stopped": stopped })))
30 })
31 }),
32 );
33
34 // shellexec → run a shell command in the agent's working directory and return output.
35 // Invoked by the `!cmd` prefix in the agent pane composer.
36 let wstore_se = state.wstore.clone();
37 engine.register_handler(
38 COMMAND_SHELL_EXEC,
39 Box::new(move |data, _ctx| {
40 let wstore = wstore_se.clone();
41 Box::pin(async move {
42 let cmd: CommandShellExecData = serde_json::from_value(data)
43 .map_err(|e| format!("shellexec: {e}"))?;
44 // Log block_id at info; keep the command at debug so secrets
45 // passed as CLI args (API tokens, passwords) don't land in
46 // ~/.agentmux/logs/ in plaintext.
47 tracing::info!(block_id = %cmd.blockid, "ShellExec");
48 tracing::debug!(command = %cmd.command, "ShellExec command");
49
50 // Reject container agents: their filesystem lives inside the
51 // Docker container, so running sh on the host gives misleading
52 // results or mutates the wrong environment. Routing through
53 // `docker exec` is tracked as a follow-up feature.
54 let block: crate::backend::obj::Block = wstore
55 .get(&cmd.blockid)
56 .map_err(|e| format!("shellexec: load block: {e}"))?
57 .ok_or_else(|| format!("shellexec: block {} not found", cmd.blockid))?;
58 let agent_mode = crate::backend::obj::meta_get_string(
59 &block.meta, "agentMode", "host",
60 );
61 if agent_mode == "container" {
62 return Err(
63 "shellexec: container agents are not supported; \
64 run the command from within the agent session instead".to_string()
65 );
66 }
67
68 let cwd: Option<std::path::PathBuf> = if cmd.working_dir.is_empty() {
69 None
70 } else {
71 let expanded = expand_home_dir_safe(&cmd.working_dir);
72 // Reject non-absolute paths to prevent relative traversal
73 // (e.g. "../etc") from resolving against the sidecar's cwd.
74 // expand_home_dir_safe turns "~" into an absolute home path;
75 // anything still relative after expansion is suspicious.
76 if !expanded.is_absolute() {
77 return Err(format!(
78 "shellexec: working_dir must be absolute, got: {}",
79 expanded.display()
80 ));
81 }
82 // Canonicalize to resolve all symlinks before handing the
83 // path to the shell. Without this a symlinked working_dir
84 // would silently run the shell in the symlink target
85 // (potentially outside the agent workspace), matching the
86 // symlink-escape protection in writeagentconfig.
87 let canonical = expanded.canonicalize()
88 .map_err(|e| format!(
89 "shellexec: cannot resolve working directory '{}': {e}",
90 expanded.display()
91 ))?;
92 Some(canonical)
93 };
94
95 // 300s process timeout matches the frontend's RPC timeout so
96 // the client sees a clean error rather than a silent EC-TIME.
97 const TIMEOUT_SECS: u64 = 300;
98 // 1 MB cap per stream — bounded during read via take() so a
99 // runaway command (`! yes`, `! dd if=/dev/zero`) cannot exhaust
100 // RAM before the timeout fires. The pipes are read concurrently
101 // to prevent deadlock when one buffer fills while the process is
102 // blocked writing to the other.
103 const MAX_OUTPUT: u64 = 1_000_000;
104
105 let mut proc = if cfg!(windows) {
106 let mut c = tokio::process::Command::new("cmd");
107 c.args(["/C", &cmd.command]);
108 c
109 } else {
110 let mut c = tokio::process::Command::new("sh");
111 c.args(["-c", &cmd.command]);
112 c
113 };
114 proc.stdout(std::process::Stdio::piped());
115 proc.stderr(std::process::Stdio::piped());
116 // kill_on_drop: when the timeout fires the Child future is
117 // dropped; without this flag the OS process keeps running
118 // (e.g. `! sleep 1000` would linger indefinitely).
119 proc.kill_on_drop(true);
120 // Unix: put the shell in its own process group so that compound
121 // commands (`! a | b`, `! foo &`) are in the same group and can
122 // all be killed at once on timeout. kill_on_drop only kills the
123 // direct sh child; without process_group(0), grandchildren
124 // get reparented to init and outlive the timeout.
125 #[cfg(unix)]
126 {
127 use std::os::unix::process::CommandExt as _;
128 proc.process_group(0);
129 }
130 if let Some(ref dir) = cwd {
131 proc.current_dir(dir);
132 }
133
134 let mut child = proc.spawn()
135 .map_err(|e| format!("shellexec: spawn failed: {e}"))?;
136
137 // Capture the PID before taking stdout/stderr (id() requires
138 // the Child to still have its stdio handles on some platforms).
139 #[cfg(unix)]
140 let child_pgid = child.id().map(|id| id as libc::pid_t);
141
142 let mut stdout_pipe = child.stdout.take().expect("stdout piped");
143 let mut stderr_pipe = child.stderr.take().expect("stderr piped");
144
145 let mut stdout_buf: Vec<u8> = Vec::new();
146 let mut stderr_buf: Vec<u8> = Vec::new();
147
148 use tokio::io::AsyncReadExt as _;
149 // Each stream's capped-read and drain run in the SAME concurrent
150 // branch. A two-phase approach (cap-read all, then drain all)
151 // deadlocks: when stdout exceeds the cap, its read_to_end returns
152 // but the process is now blocked on write() to a full pipe, so it
153 // never writes to stderr, so stderr's read_to_end never gets EOF.
154 // Both reads block, the drain phase is never reached, and the whole
155 // handler hangs for the full 300 s timeout.
156 //
157 // Solution: read MAX_OUTPUT+1 sentinel bytes per stream (≤ MAX_OUTPUT
158 // → complete; MAX_OUTPUT+1 → truncated), then immediately drain the
159 // remainder. stdout and stderr pipelines run concurrently with each
160 // other, and with child.wait().
161 let timeout_result = tokio::time::timeout(
162 std::time::Duration::from_secs(TIMEOUT_SECS),
163 async {
164 let (sout, serr, wait) = tokio::join!(
165 // stdout branch: cap-read then drain
166 async {
167 let mut take = (&mut stdout_pipe).take(MAX_OUTPUT + 1);
168 take.read_to_end(&mut stdout_buf).await
169 .map_err(|e| format!("shellexec: stdout read: {e}"))?;
170 drop(take); // release &mut borrow so stdout_pipe is usable
171 let mut sink = tokio::io::sink();
172 tokio::io::copy(&mut stdout_pipe, &mut sink).await
173 .map_err(|e| format!("shellexec: drain stdout: {e}"))?;
174 Ok::<(), String>(())
175 },
176 // stderr branch: cap-read then drain
177 async {
178 let mut take = (&mut stderr_pipe).take(MAX_OUTPUT + 1);
179 take.read_to_end(&mut stderr_buf).await
180 .map_err(|e| format!("shellexec: stderr read: {e}"))?;
181 drop(take);
182 let mut sink = tokio::io::sink();
183 tokio::io::copy(&mut stderr_pipe, &mut sink).await
184 .map_err(|e| format!("shellexec: drain stderr: {e}"))?;
185 Ok::<(), String>(())
186 },
187 child.wait(),
188 );
189 sout?;
190 serr?;
191 wait.map_err(|e| format!("shellexec: wait: {e}"))
192 }
193 )
194 .await;
195
196 // On timeout: kill_on_drop kills the direct sh/cmd child, but
197 // compound commands (`! a | b`, `! foo &`) fork grandchildren
198 // in the same process group. Kill the whole group so they don't
199 // linger. (Unix only; on Windows the job-object approach is a
200 // separate follow-up since tokio doesn't expose it yet.)
201 #[cfg(unix)]
202 if timeout_result.is_err() {
203 if let Some(pgid) = child_pgid {
204 // SAFETY: kill() is async-signal-safe; negative pid
205 // addresses the process group with id=pgid.
206 unsafe { libc::kill(-pgid, libc::SIGKILL); }
207 }
208 }
209
210 let status = timeout_result
211 .map_err(|_| format!("shellexec: timed out after {TIMEOUT_SECS}s"))??;
212
213 let format_output = |bytes: Vec<u8>| -> String {
214 // bytes.len() > MAX_OUTPUT means we read the MAX_OUTPUT+1
215 // sentinel — the stream was truncated. Checking for equality
216 // with MAX_OUTPUT (without +1) would be a false positive for
217 // commands that emit exactly MAX_OUTPUT bytes.
218 if bytes.len() > MAX_OUTPUT as usize {
219 let s = String::from_utf8_lossy(&bytes[..MAX_OUTPUT as usize]);
220 format!("{s}…[output capped at {MAX_OUTPUT} bytes]")
221 } else {
222 String::from_utf8_lossy(&bytes).into_owned()
223 }
224 };
225
226 let result = ShellExecResult {
227 exit_code: status.code().unwrap_or(1),
228 stdout: format_output(stdout_buf),
229 stderr: format_output(stderr_buf),
230 };
231 Ok(Some(serde_json::to_value(result).map_err(|e| format!("shellexec: {e}"))?))
232 })
233 }),
234 );
235}