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}