Skip to main content

lash_tools/shell/
mod.rs

1//! Built-in shell tool surface (`shell.exec` / `shell.start` /
2//! `shell.write`).
3//!
4//! This module is the *surface* layer: tool definitions, argument parsing,
5//! the [`StandardShell`] executor, prompt contributions, and the plugin
6//! factory. The process-lifecycle machinery lives in [`runtime`] and the
7//! output-buffer plumbing in [`output`].
8
9mod output;
10mod runtime;
11
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use serde_json::json;
17use tokio_util::sync::CancellationToken;
18
19use lash_core::plugin::{
20    PluginError, PluginFactory, PluginSessionContext, PluginSpec, PluginSpecFactory, SessionPlugin,
21};
22use lash_core::runtime::ProcessEventSemanticsSpec;
23use lash_core::{
24    PreparedToolCall, ProcessEventType, ProcessHandleDescriptor, ProcessInput, ProcessStartRequest,
25    ProgressSender, PromptContribution, SessionScope, SessionToolAccess, ToolCall, ToolDefinition,
26    ToolProvider, ToolResult, ToolScheduling,
27};
28
29use lash_tool_support::{
30    StaticToolExecute, StaticToolProvider, object_schema, parse_optional_bool,
31    parse_optional_usize_arg, require_str,
32};
33
34use crate::shell::output::{PollOutcome, shell_io_result, timed_out_shell_io_result};
35use crate::shell::runtime::{
36    CommonCommandParams, DEFAULT_EXEC_COMMAND_TIMEOUT_MS, ExecCommandParams,
37    PipeExecProcessRequest, ShellRuntime, StartCommandParams, WaitBehavior,
38};
39
40const SHELL_STDIN_SIGNAL: &str = "stdin";
41const SHELL_STDIN_SIGNAL_EVENT: &str = "signal.stdin";
42
43pub fn shell_prompt_contributions() -> Vec<PromptContribution> {
44    shell_prompt_contributions_for_access(&SessionToolAccess::default())
45}
46
47/// Returns the shell prompt contributions, gating the `shell.write`
48/// reference on whether that tool is actually callable in the current
49/// session.
50pub fn shell_prompt_contributions_for_access(
51    access: &SessionToolAccess,
52) -> Vec<PromptContribution> {
53    let mut command_execution = String::from(
54        "Use `shell.exec` for one-shot commands; it returns only after the process exits and successful results include `status: \"completed\"`, `done: true`, and `exit_code`. Use `shell.start` only for interactive or intentionally long-lived processes; it returns a process handle that is visible to `processes.list` and cancellable with `processes.cancel`.",
55    );
56    if tool_callable_from_authority(access, "write_stdin") {
57        command_execution.push_str(" Send stdin to running shell processes with `shell.write`.");
58    }
59    command_execution.push_str(
60        " For builds, installs, tests, migrations, service setup, and verification commands, use `shell.exec` and wait for completion before concluding.",
61    );
62    vec![
63        PromptContribution::guidance("Command Execution", command_execution),
64        PromptContribution::guidance(
65            "Git Safety",
66            "Avoid destructive git commands unless explicitly requested.",
67        ),
68    ]
69}
70
71fn tool_callable_from_authority(access: &SessionToolAccess, name: &str) -> bool {
72    if access.hides(name) {
73        return false;
74    }
75    access.tools.is_empty() || access.tools.iter().any(|tool| tool.name() == name)
76}
77
78pub struct StandardShell {
79    runtime: ShellRuntime,
80}
81
82impl StandardShell {
83    pub fn new() -> Self {
84        Self {
85            runtime: ShellRuntime::new(),
86        }
87    }
88
89    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
90        self.runtime = self.runtime.with_cwd(cwd);
91        self
92    }
93
94    fn parse_common_command_params(
95        &self,
96        args: &serde_json::Value,
97    ) -> Result<CommonCommandParams, ToolResult> {
98        let cmd = require_str(args, "cmd")?.to_string();
99        let workdir = self.runtime.resolve_workdir(
100            args.get("workdir")
101                .and_then(|value| value.as_str())
102                .filter(|value| !value.is_empty()),
103        );
104        let shell_path = args
105            .get("shell")
106            .and_then(|value| value.as_str())
107            .filter(|value| !value.is_empty())
108            .unwrap_or(&self.runtime.shell_path)
109            .to_string();
110        let login = parse_optional_bool(args, "login", false)?;
111        let allow_nonzero_exit = parse_optional_bool(args, "allow_nonzero_exit", false)?;
112        let max_output_tokens = parse_optional_usize_arg(args, "max_output_tokens", None, true, 1)?;
113
114        Ok(CommonCommandParams {
115            cmd,
116            workdir,
117            shell_path,
118            login,
119            allow_nonzero_exit,
120            max_output_tokens,
121        })
122    }
123
124    fn parse_exec_command_params(
125        &self,
126        args: &serde_json::Value,
127    ) -> Result<ExecCommandParams, ToolResult> {
128        let common = self.parse_common_command_params(args)?;
129        let timeout_ms = parse_optional_usize_arg(args, "timeout_ms", None, false, 1)?
130            .map(|value| value as u64)
131            .unwrap_or(DEFAULT_EXEC_COMMAND_TIMEOUT_MS);
132
133        Ok(ExecCommandParams {
134            cmd: common.cmd,
135            workdir: common.workdir,
136            shell_path: common.shell_path,
137            login: common.login,
138            allow_nonzero_exit: common.allow_nonzero_exit,
139            timeout_ms,
140            max_output_tokens: common.max_output_tokens,
141        })
142    }
143
144    fn parse_start_command_params(
145        &self,
146        args: &serde_json::Value,
147    ) -> Result<StartCommandParams, ToolResult> {
148        let common = self.parse_common_command_params(args)?;
149
150        Ok(StartCommandParams {
151            cmd: common.cmd,
152            workdir: common.workdir,
153            shell_path: common.shell_path,
154            login: common.login,
155            allow_nonzero_exit: common.allow_nonzero_exit,
156            max_output_tokens: common.max_output_tokens,
157        })
158    }
159
160    async fn exec_command(
161        &self,
162        params: &ExecCommandParams,
163        progress: Option<&ProgressSender>,
164        cancel: Option<CancellationToken>,
165    ) -> ToolResult {
166        let started = Instant::now();
167        let handle_id = self.runtime.allocate_handle_id();
168
169        match self
170            .runtime
171            .exec_pipe_process(PipeExecProcessRequest {
172                id: &handle_id,
173                command: &params.cmd,
174                workdir: &params.workdir,
175                login: params.login,
176                shell_path: &params.shell_path,
177                timeout: Some(Duration::from_millis(params.timeout_ms)),
178                progress,
179                max_output_tokens: params.max_output_tokens,
180                cancel,
181            })
182            .await
183        {
184            Ok(PollOutcome::Running {
185                output,
186                original_token_count,
187                full_output_path,
188                ..
189            }) => timed_out_shell_io_result(
190                &handle_id,
191                output,
192                original_token_count,
193                full_output_path.as_deref(),
194                started.elapsed().as_secs_f64(),
195                params.timeout_ms,
196                params.allow_nonzero_exit,
197            ),
198            Ok(PollOutcome::Exited {
199                output,
200                original_token_count,
201                exit_code,
202                full_output_path,
203            }) => shell_io_result(
204                &handle_id,
205                output,
206                Some(exit_code),
207                original_token_count,
208                full_output_path.as_deref(),
209                started.elapsed().as_secs_f64(),
210                params.allow_nonzero_exit,
211            ),
212            Ok(PollOutcome::Cancelled) => ToolResult::cancelled("tool call cancelled"),
213            Err(err) => ToolResult::err(json!(err)),
214        }
215    }
216
217    async fn start_command(
218        &self,
219        params: &StartCommandParams,
220        context: &lash_core::ToolContext<'_>,
221        progress: Option<&ProgressSender>,
222        cancel: Option<CancellationToken>,
223    ) -> ToolResult {
224        if let Some(process_id) = context.async_process_id() {
225            return self
226                .run_start_command_process(process_id, params, context, progress, cancel)
227                .await;
228        }
229        self.register_start_command_process(params, context).await
230    }
231
232    async fn register_start_command_process(
233        &self,
234        params: &StartCommandParams,
235        context: &lash_core::ToolContext<'_>,
236    ) -> ToolResult {
237        let process_id = context
238            .tool_call_id()
239            .filter(|id| !id.is_empty())
240            .map(str::to_string)
241            .unwrap_or_else(|| format!("shell:{}", self.runtime.allocate_handle_id()));
242        let args = start_command_process_args(params);
243        let call = PreparedToolCall::from_parts(
244            process_id.clone(),
245            "start_command",
246            args,
247            None,
248            serde_json::Value::Null,
249        );
250        let descriptor = ProcessHandleDescriptor::new(Some("shell"), Some(params.cmd.clone()));
251        let request = ProcessStartRequest::new(
252            process_id.clone(),
253            ProcessInput::ToolCall { call },
254            lash_core::ProcessOriginator::host(),
255        )
256        .with_grant(Some(lash_core::ProcessStartGrant {
257            session_scope: SessionScope::new("request-descriptor"),
258            descriptor,
259        }))
260        .with_extra_event_types([shell_signal_event_type()]);
261        match context.processes().start(request).await {
262            Ok(summary) => {
263                let mut handle = serde_json::to_value(summary).unwrap_or_else(|_| {
264                    lash_core::lashlang_bridge::process_handle_json(&process_id)
265                });
266                if let Some(object) = handle.as_object_mut() {
267                    object.insert("status".to_string(), json!("running"));
268                    object.insert("done".to_string(), json!(false));
269                    object.insert("running".to_string(), json!(true));
270                }
271                ToolResult::ok(handle)
272            }
273            Err(err) => ToolResult::err_fmt(err.to_string()),
274        }
275    }
276
277    async fn run_start_command_process(
278        &self,
279        process_id: &str,
280        params: &StartCommandParams,
281        context: &lash_core::ToolContext<'_>,
282        progress: Option<&ProgressSender>,
283        cancel: Option<CancellationToken>,
284    ) -> ToolResult {
285        let started = Instant::now();
286        let handle_id = process_id.to_string();
287
288        if let Err(err) = self.runtime.spawn_process(
289            handle_id.clone(),
290            &params.cmd,
291            &params.workdir,
292            params.login,
293            &params.shell_path,
294        ) {
295            return ToolResult::err(json!(err));
296        }
297
298        let signal_done = CancellationToken::new();
299        let signal_forwarder =
300            self.spawn_stdin_signal_forwarder(handle_id.clone(), context, signal_done.clone());
301        match self
302            .runtime
303            .wait_until_exit_or_timeout(
304                &handle_id,
305                None,
306                progress,
307                params.max_output_tokens,
308                WaitBehavior { baseline_len: 0 },
309                cancel,
310            )
311            .await
312        {
313            Ok(PollOutcome::Running { .. }) => {
314                signal_done.cancel();
315                let _ = signal_forwarder.await;
316                self.runtime.remove_process(&handle_id);
317                ToolResult::err_fmt("background shell process returned running without a timeout")
318            }
319            Ok(PollOutcome::Exited {
320                output,
321                original_token_count,
322                exit_code,
323                full_output_path,
324            }) => {
325                signal_done.cancel();
326                let _ = signal_forwarder.await;
327                self.runtime.remove_process(&handle_id);
328                shell_io_result(
329                    &handle_id,
330                    output,
331                    Some(exit_code),
332                    original_token_count,
333                    full_output_path.as_deref(),
334                    started.elapsed().as_secs_f64(),
335                    params.allow_nonzero_exit,
336                )
337            }
338            Ok(PollOutcome::Cancelled) => {
339                signal_done.cancel();
340                let _ = signal_forwarder.await;
341                self.runtime.remove_process(&handle_id);
342                ToolResult::cancelled("tool call cancelled")
343            }
344            Err(err) => {
345                signal_done.cancel();
346                let _ = signal_forwarder.await;
347                self.runtime.remove_process(&handle_id);
348                ToolResult::err(json!(err))
349            }
350        }
351    }
352
353    fn spawn_stdin_signal_forwarder(
354        &self,
355        process_id: String,
356        context: &lash_core::ToolContext<'_>,
357        done: CancellationToken,
358    ) -> tokio::task::JoinHandle<()> {
359        let runtime = self.runtime.clone();
360        let events = context.process_events();
361        tokio::spawn(async move {
362            let mut after_sequence = 0;
363            loop {
364                let event = tokio::select! {
365                    _ = done.cancelled() => break,
366                    event = events.wait_event_after(SHELL_STDIN_SIGNAL_EVENT, after_sequence) => event,
367                };
368                let Ok(event) = event else {
369                    break;
370                };
371                after_sequence = event.sequence;
372                if let Some(chars) = event.payload.get("chars").and_then(|value| value.as_str()) {
373                    let _ = runtime.write_stdin(&process_id, chars).await;
374                }
375                if event
376                    .payload
377                    .get("close_stdin")
378                    .and_then(|value| value.as_bool())
379                    .unwrap_or(false)
380                {
381                    let _ = runtime.close_stdin(&process_id).await;
382                }
383            }
384        })
385    }
386
387    async fn write_stdin_call(
388        &self,
389        args: &serde_json::Value,
390        context: &lash_core::ToolContext<'_>,
391    ) -> ToolResult {
392        let process_id = match parse_process_id(args) {
393            Ok(value) => value,
394            Err(err) => return err,
395        };
396        let chars = args
397            .get("chars")
398            .and_then(|value| value.as_str())
399            .unwrap_or("");
400        let close_stdin = match parse_optional_bool(args, "close_stdin", false) {
401            Ok(value) => value,
402            Err(err) => return err,
403        };
404        match context
405            .processes()
406            .signal(
407                &process_id,
408                SHELL_STDIN_SIGNAL,
409                json!({
410                    "chars": chars,
411                    "close_stdin": close_stdin,
412                }),
413            )
414            .await
415        {
416            Ok(event) => ToolResult::ok(json!({
417                "process_id": process_id,
418                "status": "signalled",
419                "sequence": event.sequence,
420            })),
421            Err(err) => ToolResult::err_fmt(err.to_string()),
422        }
423    }
424}
425
426fn start_command_process_args(params: &StartCommandParams) -> serde_json::Value {
427    let mut args = serde_json::Map::new();
428    args.insert("cmd".to_string(), json!(params.cmd.clone()));
429    args.insert(
430        "workdir".to_string(),
431        json!(params.workdir.to_string_lossy().to_string()),
432    );
433    args.insert("shell".to_string(), json!(params.shell_path.clone()));
434    args.insert("login".to_string(), json!(params.login));
435    args.insert(
436        "allow_nonzero_exit".to_string(),
437        json!(params.allow_nonzero_exit),
438    );
439    if let Some(max_output_tokens) = params.max_output_tokens {
440        args.insert("max_output_tokens".to_string(), json!(max_output_tokens));
441    }
442    serde_json::Value::Object(args)
443}
444
445fn shell_signal_event_type() -> ProcessEventType {
446    ProcessEventType {
447        name: SHELL_STDIN_SIGNAL_EVENT.to_string(),
448        payload_schema: lash_core::LashSchema::any(),
449        semantics: ProcessEventSemanticsSpec::default(),
450    }
451}
452
453impl Default for StandardShell {
454    fn default() -> Self {
455        Self::new()
456    }
457}
458
459/// Build the cached shell tool provider (`shell.exec` / `shell.start`).
460pub fn shell_provider(shell: StandardShell) -> StaticToolProvider<StandardShell> {
461    let definitions = shell.tool_definitions();
462    StaticToolProvider::new(definitions, shell)
463}
464
465#[async_trait::async_trait]
466impl StaticToolExecute for StandardShell {
467    async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
468        let cancellation_token = call.context.cancellation_token().cloned();
469        self.dispatch(
470            call.name,
471            call.args,
472            call.context,
473            call.progress,
474            cancellation_token,
475        )
476        .await
477    }
478}
479
480impl StandardShell {
481    fn tool_definitions(&self) -> Vec<ToolDefinition> {
482        let exec_command_description = "Run a noninteractive one-shot command with stdin closed and stdout/stderr captured, then wait for it to finish. Successful results always include `status: \"completed\"`, `done: true`, `running: false`, cleaned `output`, and `exit_code`. Commands time out after 600000 ms by default; set `timeout_ms` to override the hard timeout. Timed-out commands are killed and the result has `status: \"timed_out\"`, `timed_out: true`, and no `exit_code`; by default this fails the tool. Use `shell.start` instead for interactive, TTY-dependent, or intentionally long-lived processes. Nonzero exit codes (including SIGPIPE 141 from `cmd | head`-style pipelines) fail the tool by default. Pass `allow_nonzero_exit: true` to receive the result without failure on either nonzero exit or timeout, then inspect `exit_code` and `timed_out`. ANSI/control noise is stripped from returned output. Large or truncated output may also include `full_output_path` pointing at the saved raw stream.";
483        let start_command_description = "Start an interactive or intentionally long-lived command in a PTY as a durable background process. The result is a process handle with `__handle__: \"process\"`, `id`, `process_id`, `status: \"running\"`, `done: false`, and `running: true`; use `processes.list` to see it and `processes.cancel` to stop it. Nonzero exit codes fail the eventual process output by default; pass `allow_nonzero_exit: true` only when nonzero is expected data. Use `shell.exec` for builds, installs, tests, service setup, verification, and other commands that must complete before the next step.";
484        let command_common = |command_description: &str| {
485            json!({
486                "cmd": {
487                    "type": "string",
488                    "description": command_description
489                },
490                "workdir": {
491                    "type": "string",
492                    "description": "Optional working directory to run the command in; defaults to the turn cwd."
493                },
494                "shell": {
495                    "type": "string",
496                    "description": "Shell binary to launch. Defaults to the user's default shell."
497                },
498                "login": {
499                    "type": "boolean",
500                    "default": false,
501                    "description": "Whether to run the shell with -l semantics. Defaults to false to avoid startup prompts and shell init noise."
502                },
503                "allow_nonzero_exit": {
504                    "type": "boolean",
505                    "default": false,
506                    "description": "Shell-only flag. When true, nonzero exit codes are returned as successful tool results instead of failed tool calls; inspect `exit_code` yourself. Defaults to false."
507                },
508                "max_output_tokens": {
509                    "type": "integer",
510                    "minimum": 1,
511                    "description": "Maximum number of tokens to return. Excess output will be truncated."
512                }
513            })
514        };
515        vec![
516            ToolDefinition::raw(
517                "tool:exec_command",
518                "exec_command",
519                exec_command_description,
520                {
521                    let mut properties = command_common("Shell command to execute.");
522                    properties["timeout_ms"] = json!({
523                        "type": "integer",
524                        "minimum": 1,
525                        "default": DEFAULT_EXEC_COMMAND_TIMEOUT_MS,
526                        "description": "Hard timeout in milliseconds. If reached before the command exits, the process is killed and the result has `status: \"timed_out\"` and `timed_out: true`. By default this fails the tool; pass `allow_nonzero_exit: true` to receive the timed-out result without failure. Defaults to 600000 ms."
527                    });
528                    object_schema(properties, &["cmd"])
529                },
530                shell_exec_output_schema(),
531            )
532            .with_examples(vec![
533                r#"await shell.exec({ cmd: "cargo test -p lash-protocol-rlm", timeout_ms: 600000 })?"#.into(),
534                r#"await shell.exec({ cmd: "test -f Cargo.lock", allow_nonzero_exit: true })?"#.into(),
535            ])
536            .with_agent_surface(lash_tool_support::agent_surface(
537                ["shell"],
538                "exec",
539                &["shell", "bash"],
540            ))
541            .with_scheduling(ToolScheduling::Serial),
542            ToolDefinition::raw(
543                "tool:start_command",
544                "start_command",
545                start_command_description,
546                object_schema(command_common("Shell command to start."), &["cmd"]),
547                shell_start_output_schema(),
548            )
549            .with_examples(vec![
550                r#"await shell.start({ cmd: "python -m http.server 8000" })?"#.into(),
551            ])
552            .with_agent_surface(lash_tool_support::agent_surface(
553                ["shell"],
554                "start",
555                &["long_running_command", "pty"],
556            ))
557            .with_scheduling(ToolScheduling::Serial),
558            ToolDefinition::raw(
559                "tool:write_stdin",
560                "write_stdin",
561                "Send bytes to stdin for a running shell process started by `shell.start`. Use `close_stdin: true` to send EOF. This only acknowledges delivery of the signal; use process lifecycle tools to inspect or cancel the background process.",
562                object_schema(
563                    json!({
564                        "process_id": {
565                            "type": "string",
566                            "description": "Process id returned by `shell.start`."
567                        },
568                        "chars": {
569                            "type": "string",
570                            "default": "",
571                            "description": "Bytes to write to stdin; may be empty when only closing stdin."
572                        },
573                        "close_stdin": {
574                            "type": "boolean",
575                            "default": false,
576                            "description": "Close stdin after writing to send EOF to the process."
577                        }
578                    }),
579                    &["process_id"],
580                ),
581                shell_write_output_schema(),
582            )
583            .with_examples(vec![
584                r#"await shell.write({ process_id: "call-shell-1", chars: "status\n" })?"#.into(),
585                r#"await shell.write({ process_id: "call-shell-1", chars: "", close_stdin: true })?"#.into(),
586            ])
587            .with_agent_surface(lash_tool_support::agent_surface(
588                ["shell"],
589                "write",
590                &["send_stdin", "poll_command"],
591            ))
592            .with_scheduling(ToolScheduling::Serial),
593        ]
594    }
595
596    async fn dispatch(
597        &self,
598        name: &str,
599        args: &serde_json::Value,
600        context: &lash_core::ToolContext<'_>,
601        progress: Option<&ProgressSender>,
602        cancel: Option<CancellationToken>,
603    ) -> ToolResult {
604        match name {
605            "exec_command" => {
606                let params = match self.parse_exec_command_params(args) {
607                    Ok(params) => params,
608                    Err(err) => return err,
609                };
610                self.exec_command(&params, progress, cancel).await
611            }
612            "start_command" => {
613                let params = match self.parse_start_command_params(args) {
614                    Ok(params) => params,
615                    Err(err) => return err,
616                };
617                self.start_command(&params, context, progress, cancel).await
618            }
619            "write_stdin" => self.write_stdin_call(args, context).await,
620            _ => ToolResult::err_fmt(format_args!("Unknown tool: {name}")),
621        }
622    }
623}
624
625fn shell_exec_output_schema() -> serde_json::Value {
626    json!({
627        "type": "object",
628        "properties": {
629            "output": { "type": "string" },
630            "status": { "type": "string", "enum": ["completed", "timed_out"] },
631            "done": { "type": "boolean" },
632            "running": { "type": "boolean" },
633            "wall_time_seconds": { "type": "number", "minimum": 0 },
634            "exit_code": { "type": "integer" },
635            "timed_out": { "type": "boolean" },
636            "error": { "type": "string" },
637            "original_token_count": { "type": "integer", "minimum": 0 },
638            "full_output_path": { "type": "string" }
639        },
640        "required": ["output", "status", "done", "running", "wall_time_seconds"],
641        "additionalProperties": false
642    })
643}
644
645fn shell_start_output_schema() -> serde_json::Value {
646    json!({
647        "type": "object",
648        "properties": {
649            "__handle__": { "type": "string", "enum": ["process"] },
650            "id": { "type": "string" },
651            "process_id": { "type": "string" },
652            "status": { "type": "string", "enum": ["running"] },
653            "done": { "type": "boolean" },
654            "running": { "type": "boolean" }
655        },
656        "required": ["__handle__", "id", "process_id", "status", "done", "running"],
657        "additionalProperties": false
658    })
659}
660
661fn shell_write_output_schema() -> serde_json::Value {
662    json!({
663        "type": "object",
664        "properties": {
665            "process_id": { "type": "string" },
666            "status": { "type": "string", "enum": ["signalled"] },
667            "sequence": { "type": "integer", "minimum": 0 }
668        },
669        "required": ["process_id", "status", "sequence"],
670        "additionalProperties": false
671    })
672}
673
674fn parse_process_id(args: &serde_json::Value) -> Result<String, ToolResult> {
675    require_str(args, "process_id").map(str::to_string)
676}
677
678/// PluginFactory for the built-in shell tool surface.
679///
680/// Wires `StandardShell` into the active session with the access-gated
681/// `shell.write` mention in the prompt contribution so the model only
682/// sees that bullet when the tool is actually callable.
683#[derive(Default)]
684pub struct StandardShellPluginFactory;
685
686impl StandardShellPluginFactory {
687    pub fn new() -> Self {
688        Self
689    }
690}
691
692impl PluginFactory for StandardShellPluginFactory {
693    fn id(&self) -> &'static str {
694        "shell"
695    }
696
697    fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
698        let tool_access = ctx.tool_access.clone();
699        let provider = Arc::new(shell_provider(StandardShell::new())) as Arc<dyn ToolProvider>;
700        PluginSpecFactory::new(
701            "shell",
702            Arc::new(move |_ctx| {
703                let provider = Arc::clone(&provider);
704                let tool_access = tool_access.clone();
705                Ok(PluginSpec::new()
706                    .with_tool_provider(provider)
707                    .with_prompt_contributor(Arc::new(move |_ctx| {
708                        let tool_access = tool_access.clone();
709                        Box::pin(
710                            async move { Ok(shell_prompt_contributions_for_access(&tool_access)) },
711                        )
712                    })))
713            }),
714        )
715        .build(ctx)
716    }
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722    use crate::shell::output::{MAX_OUTPUT, SPILL_OUTPUT_THRESHOLD, clean_terminal_output};
723    use lash_core::ProcessRegistry as _;
724    use serde_json::json;
725    use std::fs;
726    use std::sync::Arc;
727    use std::time::{SystemTime, UNIX_EPOCH};
728
729    fn test_shell() -> StaticToolProvider<StandardShell> {
730        shell_provider(StandardShell::new().with_cwd("/"))
731    }
732
733    async fn run(
734        shell: &StaticToolProvider<StandardShell>,
735        name: &str,
736        args: &serde_json::Value,
737    ) -> ToolResult {
738        lash_core::testing::run_tool(shell, name, args).await
739    }
740
741    async fn run_with_context(
742        shell: &StaticToolProvider<StandardShell>,
743        name: &str,
744        args: &serde_json::Value,
745        context: &lash_core::ToolContext<'_>,
746    ) -> ToolResult {
747        shell
748            .execute(ToolCall {
749                name,
750                args,
751                context,
752                progress: None,
753            })
754            .await
755    }
756
757    fn async_process_context(
758        process_id: &str,
759        cancel: CancellationToken,
760    ) -> lash_core::ToolContext<'static> {
761        lash_core::testing::mock_tool_context().with_async_process(process_id, cancel)
762    }
763
764    fn async_process_context_with_events(
765        process_id: &str,
766        registry: Arc<dyn lash_core::ProcessRegistry>,
767        cancel: CancellationToken,
768    ) -> lash_core::ToolContext<'static> {
769        lash_core::testing::mock_tool_context()
770            .with_async_process(process_id, cancel)
771            .with_process_events_for_testing(process_id, registry)
772    }
773
774    #[derive(Clone, Default)]
775    struct TestProcessService {
776        registry: Arc<lash_core::TestLocalProcessRegistry>,
777    }
778
779    impl TestProcessService {
780        fn registry(&self) -> Arc<lash_core::TestLocalProcessRegistry> {
781            Arc::clone(&self.registry)
782        }
783
784        fn session_scope(
785            session_id: &str,
786            scope: &lash_core::ProcessOpScope<'_>,
787        ) -> lash_core::SessionScope {
788            scope
789                .agent_frame_id()
790                .filter(|frame_id| !frame_id.is_empty())
791                .map(|frame_id| lash_core::SessionScope::for_agent_frame(session_id, frame_id))
792                .unwrap_or_else(|| lash_core::SessionScope::new(session_id))
793        }
794    }
795
796    #[async_trait::async_trait]
797    impl lash_core::ProcessService for TestProcessService {
798        async fn start_from_request(
799            &self,
800            session_id: &str,
801            request: lash_core::ProcessStartRequest,
802            scope: lash_core::ProcessOpScope<'_>,
803        ) -> Result<lash_core::ProcessHandleSummary, PluginError> {
804            let env_ref = request
805                .env_spec
806                .as_ref()
807                .map(lash_core::ProcessExecutionEnvSpec::stable_ref)
808                .transpose()
809                .map_err(|err| {
810                    PluginError::Session(format!("failed to hash test process env: {err}"))
811                })?;
812            let descriptor = request
813                .grant
814                .as_ref()
815                .map(|grant| grant.descriptor.clone())
816                .unwrap_or_default();
817            let registration = request.into_registration("shell-test-host", env_ref);
818            let record = self
819                .start(
820                    session_id,
821                    registration,
822                    lash_core::ProcessStartOptions::new().with_descriptor(descriptor.clone()),
823                    scope,
824                )
825                .await?;
826            let definition = lash_core::ProcessDefinitionSummary::from_input(record.input.as_ref());
827            Ok(lash_core::ProcessHandleSummary::new(
828                record.id,
829                descriptor,
830                lash_core::ProcessLifecycleStatus::from(record.status),
831            )
832            .with_definition(definition))
833        }
834
835        async fn start(
836            &self,
837            session_id: &str,
838            registration: lash_core::ProcessRegistration,
839            options: lash_core::ProcessStartOptions,
840            scope: lash_core::ProcessOpScope<'_>,
841        ) -> Result<lash_core::ProcessRecord, PluginError> {
842            let process_id = registration.id.clone();
843            let record = self.registry.register_process(registration).await?;
844            if let Some(descriptor) = options.descriptor {
845                self.registry
846                    .grant_handle(
847                        &Self::session_scope(session_id, &scope),
848                        &process_id,
849                        descriptor,
850                    )
851                    .await?;
852            }
853            Ok(record)
854        }
855
856        async fn await_process(
857            &self,
858            process_id: &str,
859            _scope: lash_core::ProcessOpScope<'_>,
860        ) -> Result<lash_core::ProcessAwaitOutput, PluginError> {
861            self.registry.await_process(process_id).await
862        }
863
864        async fn list_visible(
865            &self,
866            session_id: &str,
867            mode: lash_core::ProcessListMode,
868            scope: lash_core::ProcessOpScope<'_>,
869        ) -> Result<Vec<lash_core::runtime::ProcessHandleGrantEntry>, PluginError> {
870            let session_scope = Self::session_scope(session_id, &scope);
871            match mode {
872                lash_core::ProcessListMode::Live => {
873                    self.registry.list_live_handle_grants(&session_scope).await
874                }
875                lash_core::ProcessListMode::All => {
876                    self.registry.list_handle_grants(&session_scope).await
877                }
878            }
879        }
880
881        async fn validate_visible(
882            &self,
883            session_id: &str,
884            process_ids: &[String],
885            scope: lash_core::ProcessOpScope<'_>,
886        ) -> Result<(), PluginError> {
887            let session_scope = Self::session_scope(session_id, &scope);
888            for process_id in process_ids {
889                if !self
890                    .registry
891                    .has_handle_grant(&session_scope, process_id)
892                    .await?
893                {
894                    return Err(PluginError::Session(format!(
895                        "process handle `{process_id}` is not live or visible in this session"
896                    )));
897                }
898            }
899            Ok(())
900        }
901
902        async fn cancel(
903            &self,
904            _session_id: &str,
905            process_id: &str,
906            _scope: lash_core::ProcessOpScope<'_>,
907        ) -> Result<lash_core::ProcessRecord, PluginError> {
908            self.registry
909                .append_event(
910                    process_id,
911                    lash_core::ProcessEventAppendRequest::cancel_requested(
912                        process_id,
913                        Some("requested by test".to_string()),
914                    ),
915                )
916                .await?;
917            self.registry
918                .get_process(process_id)
919                .await
920                .ok_or_else(|| PluginError::Session(format!("unknown process `{process_id}`")))
921        }
922
923        async fn signal(
924            &self,
925            _session_id: &str,
926            process_id: &str,
927            signal_name: String,
928            signal_id: String,
929            payload: serde_json::Value,
930            _scope: lash_core::ProcessOpScope<'_>,
931        ) -> Result<lash_core::ProcessEvent, PluginError> {
932            let event_type = lash_core::process_signal_event_type(&signal_name)?;
933            self.registry
934                .append_event(
935                    process_id,
936                    lash_core::ProcessEventAppendRequest::new(event_type, payload).with_replay_key(
937                        format!("process:{process_id}:signal.{signal_name}:{signal_id}"),
938                    ),
939                )
940                .await
941                .map(|result| result.event)
942        }
943
944        async fn transfer(
945            &self,
946            _from_session_id: &str,
947            _to_session_id: &str,
948            _process_ids: Vec<String>,
949            _scope: lash_core::ProcessOpScope<'_>,
950        ) -> Result<(), PluginError> {
951            Ok(())
952        }
953
954        async fn cancel_unreferenced(
955            &self,
956            _session_id: &str,
957            _keep_process_ids: Vec<String>,
958            _scope: lash_core::ProcessOpScope<'_>,
959        ) -> Result<Vec<lash_core::ProcessRecord>, PluginError> {
960            Ok(Vec::new())
961        }
962    }
963
964    fn context_with_processes(
965        service: Arc<TestProcessService>,
966        tool_call_id: &str,
967    ) -> lash_core::ToolContext<'static> {
968        let host = Arc::new(lash_core::testing::MockSessionManager::default());
969        let processes: Arc<dyn lash_core::ProcessService> = service;
970        lash_core::ToolContext::__for_testing(
971            "test-session".to_string(),
972            host.clone(),
973            host.clone(),
974            host,
975            processes,
976            Arc::new(lash_core::InMemoryAttachmentStore::new()),
977            lash_core::DirectCompletionClient::from_fn(|_, _| {
978                Err(lash_core::PluginError::Session(
979                    "direct completions are unavailable in shell tests".to_string(),
980                ))
981            }),
982            Some(tool_call_id.to_string()),
983        )
984    }
985
986    async fn register_signal_target(
987        registry: &lash_core::TestLocalProcessRegistry,
988        process_id: &str,
989    ) {
990        registry
991            .register_process(
992                lash_core::ProcessRegistration::new(
993                    process_id,
994                    lash_core::ProcessInput::External {
995                        metadata: serde_json::json!({}),
996                    },
997                    lash_core::ProcessProvenance::host("shell-test-host"),
998                )
999                .with_extra_event_types([shell_signal_event_type()]),
1000            )
1001            .await
1002            .expect("register process");
1003        registry
1004            .grant_handle(
1005                &lash_core::SessionScope::new("test-session"),
1006                process_id,
1007                lash_core::ProcessHandleDescriptor::new(Some("shell"), Some("test")),
1008            )
1009            .await
1010            .expect("grant handle");
1011    }
1012
1013    #[tokio::test]
1014    async fn exec_command_returns_exit_code_when_command_finishes() {
1015        let shell = test_shell();
1016        let result = run(&shell, "exec_command", &json!({"cmd": "echo hello"})).await;
1017        assert!(result.is_success());
1018        assert!(result.value_for_projection().get("session_id").is_none());
1019        assert_eq!(result.value_for_projection()["status"], "completed");
1020        assert_eq!(result.value_for_projection()["done"], true);
1021        assert_eq!(result.value_for_projection()["running"], false);
1022        assert_eq!(result.value_for_projection()["exit_code"], 0);
1023        assert!(
1024            result.value_for_projection()["wall_time_seconds"]
1025                .as_f64()
1026                .is_some()
1027        );
1028        assert!(
1029            result.value_for_projection()["output"]
1030                .as_str()
1031                .unwrap()
1032                .contains("hello")
1033        );
1034    }
1035
1036    #[tokio::test]
1037    async fn exec_command_waits_for_process_exit() {
1038        let shell = shell_provider(StandardShell::new().with_cwd("/"));
1039        let result = run(
1040            &shell,
1041            "exec_command",
1042            &json!({"cmd": "sleep 0.05; echo done"}),
1043        )
1044        .await;
1045        assert!(result.is_success(), "{}", result.value_for_projection());
1046        assert!(result.value_for_projection().get("session_id").is_none());
1047        assert_eq!(result.value_for_projection()["status"], "completed");
1048        assert_eq!(result.value_for_projection()["done"], true);
1049        assert_eq!(result.value_for_projection()["exit_code"], 0);
1050        assert!(
1051            result.value_for_projection()["output"]
1052                .as_str()
1053                .unwrap()
1054                .contains("done")
1055        );
1056    }
1057
1058    #[tokio::test]
1059    async fn exec_command_runs_without_a_tty() {
1060        let shell = test_shell();
1061        let result = run(
1062            &shell,
1063            "exec_command",
1064            &json!({"cmd": "if [ -t 0 ] || [ -t 1 ] || [ -t 2 ]; then echo tty; exit 1; else echo no-tty; fi"}),
1065        )
1066        .await;
1067
1068        assert!(result.is_success(), "{}", result.value_for_projection());
1069        assert_eq!(result.value_for_projection()["exit_code"], 0);
1070        assert_eq!(
1071            result.value_for_projection()["output"]
1072                .as_str()
1073                .unwrap()
1074                .trim(),
1075            "no-tty"
1076        );
1077    }
1078
1079    #[tokio::test]
1080    async fn exec_command_closes_stdin() {
1081        let shell = test_shell();
1082        let result = run(
1083            &shell,
1084            "exec_command",
1085            &json!({"cmd": "python3 -c 'import sys; print(sys.stdin.read() == \"\")'"}),
1086        )
1087        .await;
1088
1089        assert!(result.is_success(), "{}", result.value_for_projection());
1090        assert_eq!(
1091            result.value_for_projection()["output"]
1092                .as_str()
1093                .unwrap()
1094                .trim(),
1095            "True"
1096        );
1097    }
1098
1099    #[tokio::test]
1100    async fn exec_command_captures_stdout_and_stderr() {
1101        let shell = test_shell();
1102        let result = run(
1103            &shell,
1104            "exec_command",
1105            &json!({"cmd": "echo stdout-line; echo stderr-line >&2"}),
1106        )
1107        .await;
1108
1109        assert!(result.is_success(), "{}", result.value_for_projection());
1110        let result_value = result.value_for_projection();
1111        let output = result_value["output"].as_str().unwrap();
1112        assert!(output.contains("stdout-line"), "{output}");
1113        assert!(output.contains("stderr-line"), "{output}");
1114    }
1115
1116    #[tokio::test]
1117    async fn start_command_runs_in_a_pty() {
1118        let shell = test_shell();
1119        let ctx = async_process_context("shell-pty", CancellationToken::new());
1120        let result = run_with_context(
1121            &shell,
1122            "start_command",
1123            &json!({"cmd": "if [ -t 0 ] && [ -t 1 ]; then echo tty; else echo no-tty; exit 1; fi"}),
1124            &ctx,
1125        )
1126        .await;
1127
1128        assert!(result.is_success(), "{}", result.value_for_projection());
1129        assert_eq!(result.value_for_projection()["exit_code"], 0);
1130        assert_eq!(
1131            result.value_for_projection()["output"]
1132                .as_str()
1133                .unwrap()
1134                .trim(),
1135            "tty"
1136        );
1137    }
1138
1139    #[tokio::test]
1140    async fn exec_command_timeout_kills_and_fails_running_process() {
1141        let shell = shell_provider(StandardShell::new().with_cwd("/"));
1142        let result = run(
1143            &shell,
1144            "exec_command",
1145            &json!({"cmd": "printf started; sleep 5", "timeout_ms": 50}),
1146        )
1147        .await;
1148        assert!(!result.is_success(), "{}", result.value_for_projection());
1149        assert_eq!(result.value_for_projection()["status"], "timed_out");
1150        assert_eq!(result.value_for_projection()["done"], true);
1151        assert_eq!(result.value_for_projection()["running"], false);
1152        assert!(result.value_for_projection().get("session_id").is_none());
1153        assert!(
1154            result.value_for_projection()["output"]
1155                .as_str()
1156                .unwrap_or("")
1157                .contains("started")
1158        );
1159    }
1160
1161    #[tokio::test]
1162    async fn exec_command_timeout_kills_process_group_children() {
1163        let shell = test_shell();
1164        let marker = std::env::temp_dir().join(format!(
1165            "lash-exec-timeout-child-{}",
1166            SystemTime::now()
1167                .duration_since(UNIX_EPOCH)
1168                .unwrap()
1169                .as_nanos()
1170        ));
1171        let cmd = format!(
1172            "sh -c 'sleep 0.4; echo leaked > {}' & wait",
1173            marker.display()
1174        );
1175
1176        let result = run(
1177            &shell,
1178            "exec_command",
1179            &json!({"cmd": cmd, "timeout_ms": 50, "allow_nonzero_exit": true}),
1180        )
1181        .await;
1182
1183        assert!(result.is_success(), "{}", result.value_for_projection());
1184        assert_eq!(result.value_for_projection()["status"], "timed_out");
1185        tokio::time::sleep(Duration::from_millis(600)).await;
1186        assert!(!marker.exists(), "timed-out child process wrote marker");
1187        let _ = fs::remove_file(marker);
1188    }
1189
1190    #[tokio::test]
1191    async fn start_command_registers_process_handle() {
1192        let shell = shell_provider(StandardShell::new().with_cwd("/"));
1193        let service = Arc::new(TestProcessService::default());
1194        let ctx = context_with_processes(Arc::clone(&service), "shell-call-1");
1195        let result = run_with_context(
1196            &shell,
1197            "start_command",
1198            &json!({"cmd": "sleep 1; echo done"}),
1199            &ctx,
1200        )
1201        .await;
1202        assert!(result.is_success(), "{}", result.value_for_projection());
1203        assert_eq!(result.value_for_projection()["status"], "running");
1204        assert_eq!(result.value_for_projection()["done"], false);
1205        assert_eq!(result.value_for_projection()["running"], true);
1206        assert_eq!(result.value_for_projection()["__handle__"], "process");
1207        assert_eq!(result.value_for_projection()["id"], "shell-call-1");
1208        assert_eq!(result.value_for_projection()["process_id"], "shell-call-1");
1209
1210        let entries = service
1211            .registry()
1212            .list_live_handle_grants(&lash_core::SessionScope::new("test-session"))
1213            .await
1214            .expect("list live handles");
1215        assert_eq!(entries.len(), 1);
1216        assert_eq!(entries[0].0.process_id, "shell-call-1");
1217        assert_eq!(entries[0].0.descriptor.kind.as_deref(), Some("shell"));
1218    }
1219
1220    #[tokio::test]
1221    async fn write_stdin_emits_process_signal() {
1222        let shell = test_shell();
1223        let service = Arc::new(TestProcessService::default());
1224        let registry = service.registry();
1225        register_signal_target(registry.as_ref(), "shell-call-1").await;
1226        let ctx = context_with_processes(Arc::clone(&service), "write-call-1");
1227
1228        let result = run_with_context(
1229            &shell,
1230            "write_stdin",
1231            &json!({"process_id": "shell-call-1", "chars": "hello\n", "close_stdin": true}),
1232            &ctx,
1233        )
1234        .await;
1235        assert!(result.is_success(), "{}", result.value_for_projection());
1236        assert_eq!(result.value_for_projection()["status"], "signalled");
1237        assert_eq!(result.value_for_projection()["process_id"], "shell-call-1");
1238
1239        let events = service
1240            .registry()
1241            .events_after("shell-call-1", 0)
1242            .await
1243            .expect("events");
1244        assert_eq!(events.len(), 1);
1245        assert_eq!(events[0].event_type, SHELL_STDIN_SIGNAL_EVENT);
1246        assert_eq!(events[0].payload["chars"], "hello\n");
1247        assert_eq!(events[0].payload["close_stdin"], true);
1248    }
1249
1250    #[tokio::test]
1251    async fn start_command_process_consumes_stdin_signals() {
1252        let shell = test_shell();
1253        let registry = Arc::new(lash_core::TestLocalProcessRegistry::default());
1254        register_signal_target(registry.as_ref(), "shell-worker").await;
1255        let registry_dyn: Arc<dyn lash_core::ProcessRegistry> = registry.clone();
1256        let ctx = Arc::new(async_process_context_with_events(
1257            "shell-worker",
1258            registry_dyn,
1259            CancellationToken::new(),
1260        ));
1261        let args = Arc::new(json!({
1262            "cmd": "python3 -u -c 'import sys; line = sys.stdin.readline(); print(\"got:\" + line.strip())'",
1263            "login": false,
1264        }));
1265        let shell = Arc::new(shell);
1266        let worker = {
1267            let shell = Arc::clone(&shell);
1268            let ctx = Arc::clone(&ctx);
1269            let args = Arc::clone(&args);
1270            tokio::spawn(async move {
1271                shell
1272                    .execute(ToolCall {
1273                        name: "start_command",
1274                        args: &args,
1275                        context: &ctx,
1276                        progress: None,
1277                    })
1278                    .await
1279            })
1280        };
1281
1282        tokio::time::sleep(Duration::from_millis(100)).await;
1283        registry
1284            .append_event(
1285                "shell-worker",
1286                lash_core::ProcessEventAppendRequest::new(
1287                    SHELL_STDIN_SIGNAL_EVENT,
1288                    json!({"chars": "hello\n", "close_stdin": false}),
1289                ),
1290            )
1291            .await
1292            .expect("signal");
1293
1294        let result = worker.await.expect("worker task");
1295        assert!(result.is_success(), "{}", result.value_for_projection());
1296        assert_eq!(result.value_for_projection()["exit_code"], 0);
1297        assert!(
1298            result.value_for_projection()["output"]
1299                .as_str()
1300                .unwrap()
1301                .contains("got:hello")
1302        );
1303    }
1304
1305    #[tokio::test]
1306    async fn start_command_process_can_close_stdin_from_signal() {
1307        let shell = test_shell();
1308        let registry = Arc::new(lash_core::TestLocalProcessRegistry::default());
1309        register_signal_target(registry.as_ref(), "shell-close-stdin").await;
1310        let registry_dyn: Arc<dyn lash_core::ProcessRegistry> = registry.clone();
1311        let ctx = Arc::new(async_process_context_with_events(
1312            "shell-close-stdin",
1313            registry_dyn,
1314            CancellationToken::new(),
1315        ));
1316        let args = Arc::new(json!({"cmd": "cat", "login": false}));
1317        let shell = Arc::new(shell);
1318        let worker = {
1319            let shell = Arc::clone(&shell);
1320            let ctx = Arc::clone(&ctx);
1321            let args = Arc::clone(&args);
1322            tokio::spawn(async move {
1323                shell
1324                    .execute(ToolCall {
1325                        name: "start_command",
1326                        args: &args,
1327                        context: &ctx,
1328                        progress: None,
1329                    })
1330                    .await
1331            })
1332        };
1333
1334        tokio::time::sleep(Duration::from_millis(100)).await;
1335        registry
1336            .append_event(
1337                "shell-close-stdin",
1338                lash_core::ProcessEventAppendRequest::new(
1339                    SHELL_STDIN_SIGNAL_EVENT,
1340                    json!({"chars": "hello", "close_stdin": true}),
1341                ),
1342            )
1343            .await
1344            .expect("signal");
1345
1346        let result = worker.await.expect("worker task");
1347        assert!(result.is_success(), "{}", result.value_for_projection());
1348        assert_eq!(result.value_for_projection()["exit_code"], 0);
1349        assert!(
1350            result.value_for_projection()["output"]
1351                .as_str()
1352                .unwrap()
1353                .contains("hello")
1354        );
1355    }
1356
1357    #[tokio::test]
1358    async fn start_command_process_nonzero_exit_fails_by_default() {
1359        let shell = test_shell();
1360        let ctx = async_process_context("shell-exit-7", CancellationToken::new());
1361        let result = run_with_context(
1362            &shell,
1363            "start_command",
1364            &json!({"cmd": "exit 7", "login": false}),
1365            &ctx,
1366        )
1367        .await;
1368
1369        assert!(!result.is_success(), "{}", result.value_for_projection());
1370        assert_eq!(result.value_for_projection()["exit_code"], 7);
1371        assert_eq!(
1372            result.value_for_projection()["error"].as_str(),
1373            Some("Command exited with code 7")
1374        );
1375    }
1376
1377    #[tokio::test]
1378    async fn start_command_process_reports_full_output_path_when_token_truncated() {
1379        let shell = test_shell();
1380        let ctx = async_process_context("shell-token-truncated", CancellationToken::new());
1381        let result = run_with_context(
1382            &shell,
1383            "start_command",
1384            &json!({"cmd": "python3 -c 'print(\"segment \" * 5000)'", "login": false, "max_output_tokens": 24}),
1385            &ctx,
1386        )
1387        .await;
1388
1389        assert!(result.is_success(), "{}", result.value_for_projection());
1390        let result_value = result.value_for_projection();
1391        let output = result_value["output"].as_str().unwrap();
1392        let full_output_path = result_value["full_output_path"].as_str().unwrap();
1393        let full_output = fs::read_to_string(full_output_path).expect("full output file");
1394        assert!(output.contains("[truncated]"));
1395        assert!(full_output.contains("segment segment"));
1396    }
1397
1398    #[tokio::test]
1399    async fn start_command_process_completes_short_lived_commands() {
1400        let shell = test_shell();
1401        let cmd = "python3 -u -c 'import sys; line = sys.stdin.readline(); print(\"got:\" + line.strip())'";
1402        let registry = Arc::new(lash_core::TestLocalProcessRegistry::default());
1403        register_signal_target(registry.as_ref(), "shell-short").await;
1404        let registry_dyn: Arc<dyn lash_core::ProcessRegistry> = registry.clone();
1405        let ctx = Arc::new(async_process_context_with_events(
1406            "shell-short",
1407            registry_dyn,
1408            CancellationToken::new(),
1409        ));
1410        let args = Arc::new(json!({"cmd": cmd, "login": false}));
1411        let shell = Arc::new(shell);
1412        let worker = {
1413            let shell = Arc::clone(&shell);
1414            let ctx = Arc::clone(&ctx);
1415            let args = Arc::clone(&args);
1416            tokio::spawn(async move {
1417                shell
1418                    .execute(ToolCall {
1419                        name: "start_command",
1420                        args: &args,
1421                        context: &ctx,
1422                        progress: None,
1423                    })
1424                    .await
1425            })
1426        };
1427
1428        tokio::time::sleep(Duration::from_millis(100)).await;
1429        registry
1430            .append_event(
1431                "shell-short",
1432                lash_core::ProcessEventAppendRequest::new(
1433                    SHELL_STDIN_SIGNAL_EVENT,
1434                    json!({"chars": "hello\n", "close_stdin": false}),
1435                ),
1436            )
1437            .await
1438            .expect("signal");
1439
1440        let result = worker.await.expect("worker task");
1441        assert!(result.is_success());
1442        assert!(result.value_for_projection().get("session_id").is_none());
1443        assert_eq!(result.value_for_projection()["exit_code"], 0);
1444        assert!(
1445            result.value_for_projection()["output"]
1446                .as_str()
1447                .unwrap()
1448                .contains("got:hello")
1449        );
1450    }
1451
1452    #[tokio::test]
1453    async fn exec_command_honors_workdir() {
1454        let shell = shell_provider(StandardShell::new().with_cwd("/"));
1455        let result = run(
1456            &shell,
1457            "exec_command",
1458            &json!({"cmd": "pwd", "workdir": "tmp"}),
1459        )
1460        .await;
1461        assert!(result.is_success());
1462        assert_eq!(
1463            result.value_for_projection()["output"]
1464                .as_str()
1465                .unwrap()
1466                .trim_end(),
1467            "/tmp"
1468        );
1469    }
1470
1471    #[tokio::test]
1472    async fn exec_command_pipeline_failure_uses_pipefail() {
1473        let shell = test_shell();
1474        let result = run(&shell, "exec_command", &json!({"cmd": "false | cat"})).await;
1475        assert!(!result.is_success());
1476        assert_ne!(result.value_for_projection()["exit_code"], 0);
1477        assert_eq!(
1478            result.value_for_projection()["error"].as_str(),
1479            Some("Command exited with code 1")
1480        );
1481    }
1482
1483    #[tokio::test]
1484    async fn exec_command_allow_nonzero_exit_returns_nonzero_as_success() {
1485        let shell = test_shell();
1486        let result = run(
1487            &shell,
1488            "exec_command",
1489            &json!({"cmd": "echo expected failure; exit 7", "allow_nonzero_exit": true}),
1490        )
1491        .await;
1492        assert!(result.is_success(), "{}", result.value_for_projection());
1493        assert_eq!(result.value_for_projection()["exit_code"], 7);
1494        assert!(result.value_for_projection()["error"].is_null());
1495        assert!(
1496            result.value_for_projection()["output"]
1497                .as_str()
1498                .unwrap()
1499                .contains("expected failure")
1500        );
1501    }
1502
1503    #[tokio::test]
1504    async fn exec_command_reports_full_output_path_when_token_truncated() {
1505        let shell = test_shell();
1506        let result = run(
1507            &shell,
1508            "exec_command",
1509            &json!({"cmd": "python3 -c 'print(\"hello \" * 4000)'", "max_output_tokens": 16, "login": false}),
1510        )
1511        .await;
1512        assert!(result.is_success(), "{}", result.value_for_projection());
1513        let result_value = result.value_for_projection();
1514        let output = result_value["output"].as_str().unwrap();
1515        let full_output_path = result_value["full_output_path"].as_str().unwrap();
1516        let full_output = fs::read_to_string(full_output_path).expect("full output file");
1517        assert!(output.contains("[truncated]"));
1518        assert!(full_output.contains("hello hello"));
1519    }
1520
1521    #[tokio::test]
1522    async fn exec_command_spills_full_output_when_buffer_overflows() {
1523        let shell = test_shell();
1524        let result = run(
1525            &shell,
1526            "exec_command",
1527            &json!({"cmd": format!("python3 -c 'import sys; sys.stdout.write(\"x\" * {})'", MAX_OUTPUT + 8192), "login": false}),
1528        )
1529        .await;
1530        assert!(result.is_success(), "{}", result.value_for_projection());
1531        let result_value = result.value_for_projection();
1532        let output = result_value["output"].as_str().unwrap();
1533        let full_output_path = result_value["full_output_path"].as_str().unwrap();
1534        let full_output = fs::read_to_string(full_output_path).expect("full output file");
1535        assert!(output.contains("[truncated]"));
1536        assert!(full_output.len() >= MAX_OUTPUT + 8192);
1537    }
1538
1539    #[tokio::test]
1540    async fn exec_command_reports_full_output_path_for_large_output() {
1541        let shell = test_shell();
1542        let result = run(
1543            &shell,
1544            "exec_command",
1545            &json!({"cmd": format!("python3 -c 'import sys; sys.stdout.write(\"x\" * {})'", SPILL_OUTPUT_THRESHOLD + 4096), "login": false}),
1546        )
1547        .await;
1548        assert!(result.is_success(), "{}", result.value_for_projection());
1549        let result_value = result.value_for_projection();
1550        assert!(result_value["output"].as_str().is_some());
1551        let full_output_path = result_value["full_output_path"].as_str().unwrap();
1552        let full_output = fs::read_to_string(full_output_path).expect("full output file");
1553        assert!(full_output.len() >= SPILL_OUTPUT_THRESHOLD + 4096);
1554    }
1555
1556    #[test]
1557    fn shell_definitions_are_compact_and_non_empty() {
1558        let shell = StandardShell::default();
1559        let defs = shell.tool_definitions();
1560        assert_eq!(defs.len(), 3);
1561        assert!(defs.iter().all(|def| !def.description().is_empty()));
1562    }
1563
1564    #[test]
1565    fn shell_definitions_document_distinct_result_shapes() {
1566        let shell = StandardShell::default();
1567        let defs = shell.tool_definitions();
1568        let exec = defs
1569            .iter()
1570            .find(|definition| definition.name() == "exec_command")
1571            .expect("exec_command definition");
1572        let start = defs
1573            .iter()
1574            .find(|definition| definition.name() == "start_command")
1575            .expect("start_command definition");
1576        let write = defs
1577            .iter()
1578            .find(|definition| definition.name() == "write_stdin")
1579            .expect("write_stdin definition");
1580
1581        assert!(
1582            exec.compact_contract()
1583                .render_signature()
1584                .contains("exit_code")
1585        );
1586        assert!(
1587            start
1588                .compact_contract()
1589                .render_signature()
1590                .contains("__handle__")
1591        );
1592        assert!(
1593            write
1594                .compact_contract()
1595                .render_signature()
1596                .contains("sequence")
1597        );
1598    }
1599
1600    #[test]
1601    fn start_command_contract_uses_process_handles() {
1602        let shell = StandardShell::default();
1603        let definition = shell
1604            .tool_definitions()
1605            .into_iter()
1606            .find(|definition| definition.name() == "start_command")
1607            .expect("start_command definition");
1608        let properties = definition
1609            .contract
1610            .input_schema
1611            .get("properties")
1612            .and_then(serde_json::Value::as_object)
1613            .expect("properties");
1614
1615        assert!(!properties.contains_key("poll_ms"));
1616        assert!(!properties.contains_key("timeout_ms"));
1617        assert!(definition.description().contains("processes.list"));
1618        assert!(definition.description().contains("processes.cancel"));
1619    }
1620
1621    #[test]
1622    fn exec_command_defaults_to_non_login_shell() {
1623        let shell = StandardShell::default();
1624        let params = shell
1625            .parse_exec_command_params(&json!({"cmd": "echo hello"}))
1626            .expect("params");
1627
1628        assert!(!params.login);
1629    }
1630
1631    #[test]
1632    fn exec_command_defaults_to_generous_timeout() {
1633        let shell = StandardShell::default();
1634        let params = shell
1635            .parse_exec_command_params(&json!({"cmd": "echo hello"}))
1636            .expect("params");
1637
1638        assert_eq!(params.timeout_ms, DEFAULT_EXEC_COMMAND_TIMEOUT_MS);
1639    }
1640
1641    #[test]
1642    fn exec_command_timeout_schema_documents_default() {
1643        let shell = StandardShell::default();
1644        let definition = shell
1645            .tool_definitions()
1646            .into_iter()
1647            .find(|definition| definition.name() == "exec_command")
1648            .expect("exec_command definition");
1649        let properties = definition
1650            .contract
1651            .input_schema
1652            .get("properties")
1653            .and_then(serde_json::Value::as_object)
1654            .expect("properties");
1655
1656        assert_eq!(
1657            properties["timeout_ms"]["default"],
1658            DEFAULT_EXEC_COMMAND_TIMEOUT_MS
1659        );
1660        assert!(
1661            definition
1662                .description()
1663                .contains("Commands time out after 600000 ms by default")
1664        );
1665    }
1666
1667    #[test]
1668    fn clean_terminal_output_strips_ansi_and_controls() {
1669        let raw = "\x1b[?2004h\x1b[31mred\x1b[0m\r\nab\x08c\x1b]0;title\x07\x00";
1670
1671        assert_eq!(clean_terminal_output(raw), "red\nac");
1672    }
1673
1674    #[tokio::test]
1675    async fn exec_command_cancel_token_kills_running_child() {
1676        use std::time::Instant;
1677
1678        let shell = test_shell();
1679        let token = CancellationToken::new();
1680        let ctx = lash_core::testing::mock_tool_context().with_async_process("test", token.clone());
1681
1682        // A long-running sleep that would otherwise hold the tool call for
1683        // 5s. The dispatcher must return promptly once the token fires, and
1684        // the pipe-backed process group must be killed rather than left to run.
1685        let args = json!({
1686            "cmd": "sleep 5",
1687            "login": false,
1688        });
1689
1690        let cancel_handle = {
1691            let token = token.clone();
1692            tokio::spawn(async move {
1693                tokio::time::sleep(Duration::from_millis(100)).await;
1694                token.cancel();
1695            })
1696        };
1697
1698        let started = Instant::now();
1699        let result = shell
1700            .execute(ToolCall {
1701                name: "exec_command",
1702                args: &args,
1703                context: &ctx,
1704                progress: None,
1705            })
1706            .await;
1707        let elapsed = started.elapsed();
1708        let _ = cancel_handle.await;
1709
1710        assert!(
1711            elapsed < Duration::from_secs(1),
1712            "cancelled dispatch should return in under 1s (took {elapsed:?})"
1713        );
1714        assert!(!result.is_success(), "cancelled result should be an error");
1715        assert!(
1716            result
1717                .value_for_projection()
1718                .to_string()
1719                .contains("tool call cancelled")
1720        );
1721    }
1722
1723    #[tokio::test]
1724    async fn start_command_cancel_token_kills_running_child() {
1725        use std::time::Instant;
1726
1727        let shell = test_shell();
1728        let token = CancellationToken::new();
1729        let ctx = async_process_context("shell-cancel", token.clone());
1730        let args = json!({
1731            "cmd": "sleep 5",
1732            "login": false,
1733        });
1734        let cancel_handle = {
1735            let token = token.clone();
1736            tokio::spawn(async move {
1737                tokio::time::sleep(Duration::from_millis(100)).await;
1738                token.cancel();
1739            })
1740        };
1741
1742        let started = Instant::now();
1743        let result = run_with_context(&shell, "start_command", &args, &ctx).await;
1744        let elapsed = started.elapsed();
1745        let _ = cancel_handle.await;
1746
1747        assert!(
1748            elapsed < Duration::from_secs(1),
1749            "cancelled dispatch should return in under 1s (took {elapsed:?})"
1750        );
1751        assert!(!result.is_success(), "cancelled result should be an error");
1752        assert!(
1753            result
1754                .value_for_projection()
1755                .to_string()
1756                .contains("tool call cancelled")
1757        );
1758    }
1759}