Skip to main content

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