1mod 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
44pub 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: ¶ms.cmd,
171 workdir: ¶ms.workdir,
172 login: params.login,
173 shell_path: ¶ms.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 ¶ms.cmd,
284 ¶ms.workdir,
285 params.login,
286 ¶ms.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
451pub 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(¶ms, 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(¶ms, 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#[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 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}