Skip to main content

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}