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