1#![allow(dead_code)]
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::{Arc, Mutex};
6use std::time::Instant;
7
8use tokio::sync::broadcast;
9use tokio::task::JoinHandle;
10
11use crate::error::RuntimeError;
12
13pub const DEFAULT_ROWS: u16 = 24;
14pub const DEFAULT_COLS: u16 = 80;
15const STREAM_CHANNEL_CAPACITY: usize = 256;
16
17#[derive(Debug, Clone, Hash, PartialEq, Eq)]
18pub struct TermHandle {
19 pub session_id: String,
20 pub local_id: u64,
21}
22
23impl TermHandle {
24 pub fn parse(s: &str) -> Option<Self> {
25 let rest = s.strip_prefix("term_")?;
26 let idx = rest.rfind('_')?;
27 let session_id = rest[..idx].to_string();
28 let local_id = rest[idx + 1..].parse().ok()?;
29 Some(Self {
30 session_id,
31 local_id,
32 })
33 }
34}
35
36impl std::fmt::Display for TermHandle {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 write!(f, "term_{}_{}", self.session_id, self.local_id)
39 }
40}
41
42#[derive(Debug, Clone)]
43pub enum TermState {
44 Running {
45 pid: u32,
46 started_at: u64,
47 },
48 Exited {
49 exit_code: Option<i32>,
50 ended_at: u64,
51 },
52 Failed {
53 error: String,
54 ended_at: u64,
55 },
56 Killed {
57 ended_at: u64,
58 },
59}
60
61impl TermState {
62 pub fn is_running(&self) -> bool {
63 matches!(self, TermState::Running { .. })
64 }
65
66 pub fn to_snapshot(&self) -> TermStateSnapshot {
67 match self {
68 TermState::Running { .. } => TermStateSnapshot::Running,
69 TermState::Exited { exit_code, .. } => TermStateSnapshot::Exited {
70 exit_code: *exit_code,
71 },
72 TermState::Failed { error, .. } => TermStateSnapshot::Failed {
73 error: error.clone(),
74 },
75 TermState::Killed { .. } => TermStateSnapshot::Killed,
76 }
77 }
78}
79
80#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
81#[serde(tag = "kind")]
82pub enum TermStateSnapshot {
83 Running,
84 Exited { exit_code: Option<i32> },
85 Failed { error: String },
86 Killed,
87}
88
89#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
90pub struct TerminalScreen {
91 pub rows: u16,
92 pub cols: u16,
93 pub cells: Vec<TerminalCell>,
94 pub cursor: Option<(u16, u16)>,
95 pub alt_screen: bool,
96}
97
98#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
99pub struct TerminalCell {
100 pub chars: String,
101 pub fg: TerminalColor,
102 pub bg: TerminalColor,
103 pub bold: bool,
104 pub italic: bool,
105 pub underline: bool,
106 pub inverse: bool,
107 pub dim: bool,
108 pub wide: bool,
109 #[serde(default)]
110 pub wide_continuation: bool,
111}
112
113impl Default for TerminalCell {
114 fn default() -> Self {
115 Self {
116 chars: String::new(),
117 fg: TerminalColor::Default,
118 bg: TerminalColor::Default,
119 bold: false,
120 italic: false,
121 underline: false,
122 inverse: false,
123 dim: false,
124 wide: false,
125 wide_continuation: false,
126 }
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
131pub enum TerminalColor {
132 Default,
133 Idx(u8),
134 Rgb(u8, u8, u8),
135}
136
137impl From<vt100::Color> for TerminalColor {
138 fn from(c: vt100::Color) -> Self {
139 match c {
140 vt100::Color::Default => TerminalColor::Default,
141 vt100::Color::Idx(i) => TerminalColor::Idx(i),
142 vt100::Color::Rgb(r, g, b) => TerminalColor::Rgb(r, g, b),
143 }
144 }
145}
146
147pub fn snapshot_screen(parser: &vt100::Parser) -> TerminalScreen {
148 let screen = parser.screen();
149 let (rows, cols) = screen.size();
150 let mut cells = Vec::with_capacity(rows as usize * cols as usize);
151 for row in 0..rows {
152 for col in 0..cols {
153 if let Some(c) = screen.cell(row, col) {
154 cells.push(TerminalCell {
155 chars: c.contents().to_string(),
156 fg: c.fgcolor().into(),
157 bg: c.bgcolor().into(),
158 bold: c.bold(),
159 italic: c.italic(),
160 underline: c.underline(),
161 inverse: c.inverse(),
162 dim: c.dim(),
163 wide: c.is_wide(),
164 wide_continuation: c.is_wide_continuation(),
165 });
166 } else {
167 cells.push(TerminalCell::default());
168 }
169 }
170 }
171 let cursor = if screen.hide_cursor() {
172 None
173 } else {
174 Some(screen.cursor_position())
175 };
176 TerminalScreen {
177 rows,
178 cols,
179 cells,
180 cursor,
181 alt_screen: screen.alternate_screen(),
182 }
183}
184
185pub fn parse_ansi_to_screen(body: &str) -> TerminalScreen {
195 let body = body.strip_suffix('\n').unwrap_or(body);
196 let lines: Vec<&str> = body.split('\n').collect();
197 let rows = lines.len().max(1) as u16;
198 let cols = lines
199 .iter()
200 .map(|l| l.chars().count())
201 .max()
202 .unwrap_or(0)
203 .clamp(80, 256) as u16;
204 let mut parser = vt100::Parser::new(rows, cols, 0);
205 for (i, line) in lines.iter().enumerate() {
206 if i > 0 {
207 parser.process(b"\r\n");
208 }
209 parser.process(line.as_bytes());
210 }
211 snapshot_screen(&parser)
212}
213
214#[derive(Debug, Clone)]
215pub enum TermStreamEvent {
216 Chunk {
217 bytes: Vec<u8>,
218 screen: TerminalScreen,
219 state: TermState,
220 },
221 Exited {
222 exit_code: Option<i32>,
223 },
224}
225
226pub struct TermEntry {
227 pub handle: TermHandle,
228 pub session_id: String,
229 pub pty_size: portable_pty::PtySize,
230 pub parser: Arc<Mutex<vt100::Parser>>,
231 pub writer: Mutex<Box<dyn std::io::Write + Send>>,
232 pub state: Arc<Mutex<TermState>>,
233 pub stream_tx: broadcast::Sender<TermStreamEvent>,
234 pub log_path: PathBuf,
235 pub reader_task: Mutex<Option<JoinHandle<()>>>,
236 pub child: Mutex<Option<Box<dyn portable_pty::Child + Send + Sync>>>,
237 pub master: Mutex<Option<Box<dyn portable_pty::MasterPty + Send>>>,
238 profile: Arc<Mutex<Option<crate::sandbox::TempProfile>>>,
239 pub started_at: Instant,
240 pub task_id: Mutex<Option<crate::task_registry::TaskId>>,
241}
242
243impl TermEntry {
244 pub fn snapshot(&self) -> TerminalScreen {
245 let parser = self.parser.lock().expect("parser poisoned");
246 snapshot_screen(&parser)
247 }
248
249 pub fn current_state(&self) -> TermState {
250 self.state.lock().expect("state poisoned").clone()
251 }
252
253 pub fn resize(&self, rows: u16, cols: u16) -> Result<(), RuntimeError> {
254 {
255 let mut parser = self.parser.lock().expect("parser poisoned");
256 parser.screen_mut().set_size(rows, cols);
257 }
258 let master = self.master.lock().expect("master poisoned");
259 if let Some(master) = master.as_ref() {
260 master
261 .resize(portable_pty::PtySize {
262 rows,
263 cols,
264 pixel_width: 0,
265 pixel_height: 0,
266 })
267 .map_err(|e| RuntimeError::ToolFailed(format!("term resize: pty resize: {e}")))?;
268 }
269 Ok(())
270 }
271
272 fn stop(&self) -> bool {
273 {
274 let mut state = self.state.lock().expect("state poisoned");
275 if !state.is_running() {
276 return false;
277 }
278 *state = TermState::Killed { ended_at: now_ms() };
279 }
280 let mut child = self.child.lock().expect("child poisoned");
281 if let Some(child) = child.as_mut() {
282 let _ = child.kill();
283 }
284 drop(child);
285 self.profile.lock().expect("profile poisoned").take();
286 true
287 }
288}
289
290#[derive(Default)]
291pub struct TermRegistry {
292 entries: Mutex<HashMap<String, Arc<TermEntry>>>,
293 task_registry: Option<crate::task_registry::TaskRegistry>,
294}
295
296impl TermRegistry {
297 pub fn new() -> Self {
298 Self::default()
299 }
300
301 pub fn with_task_registry(mut self, tr: crate::task_registry::TaskRegistry) -> Self {
302 self.task_registry = Some(tr);
303 self
304 }
305
306 pub fn next_handle(&self, session_id: &str) -> TermHandle {
307 let local_id = uuid::Uuid::now_v7().as_u64_pair().0;
308 TermHandle {
309 session_id: session_id.to_string(),
310 local_id,
311 }
312 }
313
314 pub fn insert(&self, entry: Arc<TermEntry>) {
315 let key = entry.handle.to_string();
316 self.entries
317 .lock()
318 .expect("entries poisoned")
319 .insert(key, entry);
320 }
321
322 pub fn get(&self, handle_str: &str) -> Option<Arc<TermEntry>> {
323 self.entries
324 .lock()
325 .expect("entries poisoned")
326 .get(handle_str)
327 .cloned()
328 }
329
330 pub fn kill_all(&self) {
331 let entries = self
332 .entries
333 .lock()
334 .expect("entries poisoned")
335 .values()
336 .cloned()
337 .collect::<Vec<_>>();
338 for entry in entries {
339 entry.stop();
340 }
341 }
342
343 pub fn lookup(
344 &self,
345 handle_str: &str,
346 session_id: &str,
347 ) -> Result<Arc<TermEntry>, RuntimeError> {
348 let handle = TermHandle::parse(handle_str).ok_or_else(|| {
349 RuntimeError::ToolFailed(format!("term: invalid handle: {handle_str}"))
350 })?;
351 if handle.session_id != session_id {
352 return Err(RuntimeError::ToolFailed(format!(
353 "term: handle {handle_str} does not belong to session {session_id}"
354 )));
355 }
356 self.get(handle_str).ok_or_else(|| {
357 RuntimeError::ToolFailed(format!("term: handle not found: {handle_str}"))
358 })
359 }
360
361 pub fn list(&self, session_id: &str) -> Vec<(String, TermState)> {
362 self.entries
363 .lock()
364 .expect("entries poisoned")
365 .iter()
366 .filter(|(_, e)| e.session_id == session_id)
367 .map(|(k, e)| (k.clone(), e.current_state()))
368 .collect()
369 }
370}
371
372fn now_ms() -> u64 {
373 std::time::SystemTime::now()
374 .duration_since(std::time::UNIX_EPOCH)
375 .map(|d| d.as_millis() as u64)
376 .unwrap_or(0)
377}
378
379const READ_BUF_SIZE: usize = 4096;
380
381fn open_term_log(path: &Path) -> std::io::Result<std::fs::File> {
382 std::fs::OpenOptions::new()
383 .create(true)
384 .append(true)
385 .open(path)
386}
387
388impl TermRegistry {
389 #[allow(clippy::too_many_arguments)]
390 pub fn spawn_entry(
391 self: &Arc<Self>,
392 rows: u16,
393 cols: u16,
394 session_id: String,
395 session_dir: PathBuf,
396 pty_result: crate::sandbox::PtySpawnResult,
397 tui_stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
398 label: String,
399 command: String,
400 call_intent: Option<crate::message::ToolCallIntent>,
401 tool_use_id: Option<String>,
402 cancel: tokio_util::sync::CancellationToken,
403 events: Option<crate::event::EventSink>,
404 flow_run_id: Option<String>,
405 ) -> Result<(TermHandle, Arc<TermEntry>), RuntimeError> {
406 self.spawn_entry_with_log_opener(
407 rows,
408 cols,
409 session_id,
410 session_dir,
411 pty_result,
412 tui_stream_tx,
413 label,
414 command,
415 call_intent,
416 tool_use_id,
417 cancel,
418 events,
419 flow_run_id,
420 open_term_log,
421 )
422 }
423
424 #[allow(clippy::too_many_arguments)]
425 fn spawn_entry_with_log_opener<F>(
426 self: &Arc<Self>,
427 rows: u16,
428 cols: u16,
429 session_id: String,
430 session_dir: PathBuf,
431 pty_result: crate::sandbox::PtySpawnResult,
432 tui_stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
433 label: String,
434 command: String,
435 call_intent: Option<crate::message::ToolCallIntent>,
436 tool_use_id: Option<String>,
437 cancel: tokio_util::sync::CancellationToken,
438 events: Option<crate::event::EventSink>,
439 flow_run_id: Option<String>,
440 open_log: F,
441 ) -> Result<(TermHandle, Arc<TermEntry>), RuntimeError>
442 where
443 F: FnOnce(&Path) -> std::io::Result<std::fs::File>,
444 {
445 let handle = self.next_handle(&session_id);
446 let handle_str = handle.to_string();
447
448 let crate::sandbox::PtySpawnResult {
449 mut child,
450 reader,
451 writer,
452 master,
453 profile,
454 } = pty_result;
455
456 if let Err(error) = std::fs::create_dir_all(&session_dir) {
457 crate::sandbox::terminate_pty_child(child.as_mut());
458 return Err(RuntimeError::ToolFailed(format!(
459 "term.spawn: create session_dir: {error}"
460 )));
461 }
462 let log_path = session_dir.join(format!("term_{}.log", handle_str));
463
464 let parser = vt100::Parser::new(rows, cols, 0);
465 let parser = Arc::new(Mutex::new(parser));
466 let state = Arc::new(Mutex::new(TermState::Running {
467 pid: 0,
468 started_at: now_ms(),
469 }));
470 let (stream_tx, _stream_rx) = broadcast::channel(STREAM_CHANNEL_CAPACITY);
471 let log_file = match open_log(&log_path) {
472 Ok(file) => file,
473 Err(error) => {
474 crate::sandbox::terminate_pty_child(child.as_mut());
475 return Err(RuntimeError::ToolFailed(format!(
476 "term.spawn: open log: {error}"
477 )));
478 }
479 };
480
481 let profile = Arc::new(Mutex::new(profile));
482 let entry = Arc::new(TermEntry {
483 handle: handle.clone(),
484 session_id: session_id.clone(),
485 pty_size: portable_pty::PtySize {
486 rows,
487 cols,
488 pixel_width: 0,
489 pixel_height: 0,
490 },
491 parser: parser.clone(),
492 writer: Mutex::new(writer),
493 state: state.clone(),
494 stream_tx: stream_tx.clone(),
495 log_path: log_path.clone(),
496 reader_task: Mutex::new(None),
497 child: Mutex::new(Some(child)),
498 master: Mutex::new(Some(master)),
499 profile: profile.clone(),
500 started_at: Instant::now(),
501 task_id: Mutex::new(None),
502 });
503
504 let kill_entry = entry.clone();
505 let task_id = self.task_registry.as_ref().map(|tr| {
506 let hook: std::sync::Arc<dyn Fn() + Send + Sync> = std::sync::Arc::new(move || {
507 kill_entry.stop();
508 });
509 tr.register_with_kill_hook(
510 crate::task_registry::TaskKind::Terminal,
511 crate::task_registry::TaskDisplay {
512 label,
513 command: Some(command),
514 },
515 handle_str.clone(),
516 session_id.clone(),
517 cancel,
518 Some(hook),
519 )
520 });
521 *entry.task_id.lock().unwrap() = task_id.clone();
522
523 let handle_for_loop = handle_str.clone();
524 let task_registry = self.task_registry.clone();
525 let join = tokio::task::spawn_blocking(move || {
526 run_reader_loop(
527 reader,
528 parser,
529 state,
530 stream_tx,
531 log_file,
532 tui_stream_tx,
533 handle_for_loop,
534 task_registry,
535 task_id,
536 events,
537 flow_run_id,
538 call_intent,
539 tool_use_id,
540 profile,
541 );
542 });
543 *entry.reader_task.lock().expect("reader_task poisoned") = Some(join);
544
545 self.insert(entry.clone());
546 Ok((handle, entry))
547 }
548}
549
550impl Drop for TermRegistry {
551 fn drop(&mut self) {
552 self.kill_all();
553 }
554}
555
556#[allow(clippy::too_many_arguments)]
557fn run_reader_loop(
558 mut reader: Box<dyn std::io::Read + Send>,
559 parser: Arc<Mutex<vt100::Parser>>,
560 state: Arc<Mutex<TermState>>,
561 stream_tx: broadcast::Sender<TermStreamEvent>,
562 mut log_file: std::fs::File,
563 tui_stream_tx: Option<tokio::sync::broadcast::Sender<crate::stream::StreamFrame>>,
564 handle: String,
565 task_registry: Option<crate::task_registry::TaskRegistry>,
566 task_id: Option<crate::task_registry::TaskId>,
567 events_sink: Option<crate::event::EventSink>,
568 flow_run_id: Option<String>,
569 call_intent: Option<crate::message::ToolCallIntent>,
570 tool_use_id: Option<String>,
571 profile: Arc<Mutex<Option<crate::sandbox::TempProfile>>>,
572) {
573 let mut buf = [0u8; READ_BUF_SIZE];
574 let mut last_screen: Option<TerminalScreen> = None;
575 loop {
576 match reader.read(&mut buf) {
577 Ok(0) => break,
578 Ok(n) => {
579 let chunk = &buf[..n];
580 let _ = log_file.write_all(chunk);
581 let screen = {
582 let mut p = parser.lock().expect("parser poisoned");
583 p.process(chunk);
584 snapshot_screen(&p)
585 };
586 let screen_changed = last_screen.as_ref() != Some(&screen);
587 let st = state.lock().expect("state poisoned").clone();
588 let _ = stream_tx.send(TermStreamEvent::Chunk {
589 bytes: chunk.to_vec(),
590 screen: screen.clone(),
591 state: st.clone(),
592 });
593 if let Some(tx) = &tui_stream_tx {
594 let tui_screen = if screen_changed {
595 last_screen = Some(screen.clone());
596 Some(screen)
597 } else {
598 None
599 };
600 let _ = tx.send(crate::stream::StreamFrame::TerminalChunk {
601 handle: handle.clone(),
602 tool_use_id: tool_use_id.clone(),
603 bytes: chunk.to_vec(),
604 screen: tui_screen,
605 state: st.to_snapshot(),
606 call_intent: call_intent.clone(),
607 run_id: flow_run_id.clone(),
608 });
609 }
610 }
611 Err(e) if e.kind() == std::io::ErrorKind::Interrupted => continue,
612 Err(_) => break,
613 }
614 }
615
616 let (exit_code, task_status) = {
617 let mut s = state.lock().expect("state poisoned");
618 if matches!(*s, TermState::Running { .. }) {
619 *s = TermState::Exited {
620 exit_code: None,
621 ended_at: now_ms(),
622 };
623 }
624 let exit_code = match &*s {
625 TermState::Exited { exit_code, .. } => *exit_code,
626 _ => None,
627 };
628 let task_status = match &*s {
629 TermState::Killed { .. } => crate::task_registry::TaskStatus::Killed,
630 TermState::Failed { .. } => crate::task_registry::TaskStatus::Err,
631 TermState::Exited {
632 exit_code: Some(code),
633 ..
634 } if *code != 0 => crate::task_registry::TaskStatus::Err,
635 TermState::Exited { .. } => crate::task_registry::TaskStatus::Ok,
636 TermState::Running { .. } => unreachable!("reader exit must finalize terminal state"),
637 };
638 (exit_code, task_status)
639 };
640
641 if let Some(sink) = &events_sink {
644 let (final_screen, final_state) = {
645 let p = parser.lock().expect("parser poisoned");
646 let screen = snapshot_screen(&p);
647 let st = state.lock().expect("state poisoned").to_snapshot();
648 (screen, st)
649 };
650 sink.emit(crate::event::Event::TerminalFinalState {
651 handle: handle.clone(),
652 screen: final_screen,
653 state: final_state,
654 });
655 }
656
657 let _ = stream_tx.send(TermStreamEvent::Exited { exit_code });
658 if let Some(tx) = &tui_stream_tx {
659 let _ = tx.send(crate::stream::StreamFrame::TerminalExited {
660 handle,
661 tool_use_id,
662 exit_code,
663 call_intent,
664 run_id: flow_run_id,
665 });
666 }
667
668 profile.lock().expect("profile poisoned").take();
669 if let (Some(tr), Some(tid)) = (task_registry, task_id) {
670 tr.finish(&tid, task_status);
671 }
672}
673
674use std::io::Write;
675
676use std::path::Path;
677
678use crate::sandbox::PtySpawnResult;
679use crate::tool::{Tier, Tool};
680use crate::value::Value;
681
682fn extract_string(
683 args: &crate::tool::ToolArgs,
684 name: &str,
685 pos: usize,
686) -> Result<String, RuntimeError> {
687 if let Some(v) = args.named(name) {
688 if let Value::Str(s) = v {
689 return Ok(s.clone());
690 }
691 return Err(RuntimeError::ToolFailed(format!(
692 "term: arg {name} must be string"
693 )));
694 }
695 if let Ok(Value::Str(s)) = args.positional(pos) {
696 return Ok(s.clone());
697 }
698 Err(RuntimeError::MissingArg(format!("term: {name}")))
699}
700
701fn extract_optional_string(args: &crate::tool::ToolArgs, name: &str) -> Option<String> {
702 args.named(name).and_then(|v| {
703 if let Value::Str(s) = v {
704 Some(s.clone())
705 } else {
706 None
707 }
708 })
709}
710
711fn extract_optional_int(args: &crate::tool::ToolArgs, name: &str) -> Option<i64> {
712 args.named(name).and_then(|v| {
713 if let Value::Int(i) = v {
714 Some(*i)
715 } else {
716 None
717 }
718 })
719}
720
721pub struct TermSpawn;
722
723impl Tool for TermSpawn {
724 fn name(&self) -> &str {
725 "term.spawn"
726 }
727 fn tier(&self) -> Tier {
728 Tier::Four
729 }
730 fn description(&self) -> Option<&str> {
731 Some(
732 "Spawn a PTY-backed interactive terminal. Supports TUI apps (vim, top, ssh, codex).\nReturns handle + state + dimensions. Does NOT return screen content — use\nterm.capture to read the screen.\n\nTypical flow:\n1. term.spawn(cmd: \"your command\", rows: 24, cols: 80)\n2. term.input(handle: \"...\", text: \"ls -la\") or key: \"enter\"\n3. term.capture(handle: \"...\") — returns screen as text by default\n4. Repeat 2-3 as needed\n5. term.kill(handle: \"...\")",
733 )
734 }
735 fn input_schema(&self) -> serde_json::Value {
736 serde_json::json!({
737 "type": "object",
738 "properties": {
739 "cmd": {"type": "string"},
740 "rows": {"type": "integer", "default": 24},
741 "cols": {"type": "integer", "default": 80},
742 "cwd": {"type": "string"},
743 "env": {"type": "object"}
744 }
745 })
746 }
747 fn invocation_provenance(
748 &self,
749 args: &crate::tool::ToolArgs,
750 ctx: &crate::tool::ToolCtx,
751 ) -> Result<crate::permission::ResourceProvenance, RuntimeError> {
752 let explicit = extract_optional_string(args, "cwd").map(std::path::PathBuf::from);
753 Ok(crate::permission::ResourceProvenance::for_ctx(ctx)
754 .with_cwd(ctx, explicit.as_deref())?
755 .with_risk(crate::trust::RiskKind::ProcessSpawn))
756 }
757
758 fn call<'a>(
759 &'a self,
760 args: crate::tool::ToolArgs,
761 ctx: &'a crate::tool::ToolCtx,
762 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
763 Box::pin(async move { spawn_impl(args, ctx).await })
764 }
765}
766
767async fn spawn_impl(
768 args: crate::tool::ToolArgs,
769 ctx: &crate::tool::ToolCtx,
770) -> crate::tool::ToolResult {
771 let cmd_str = extract_optional_string(&args, "cmd");
772 let rows = extract_optional_int(&args, "rows")
773 .map(|v| v as u16)
774 .unwrap_or(DEFAULT_ROWS)
775 .max(1);
776 let cols = extract_optional_int(&args, "cols")
777 .map(|v| v as u16)
778 .unwrap_or(DEFAULT_COLS)
779 .max(2);
780 let explicit_cwd = extract_optional_string(&args, "cwd").map(std::path::PathBuf::from);
781 let cwd = ctx.resolve_cwd(explicit_cwd.as_deref())?;
782 let env: Vec<(String, String)> = if let Some(Value::Struct(fields)) = args.named("env") {
783 fields
784 .iter()
785 .filter_map(|(k, v)| {
786 if let Value::Str(s) = v {
787 Some((k.clone(), s.clone()))
788 } else {
789 None
790 }
791 })
792 .collect()
793 } else {
794 Vec::new()
795 };
796
797 let registry = ctx
798 .term_registry
799 .clone()
800 .ok_or_else(|| RuntimeError::ToolFailed("term.spawn: registry not available".into()))?;
801 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
802 let session_dir = ctx
803 .session_dir
804 .clone()
805 .ok_or_else(|| RuntimeError::ToolFailed("term.spawn: session_dir not available".into()))?;
806
807 let pty_size = portable_pty::PtySize {
808 rows,
809 cols,
810 pixel_width: 0,
811 pixel_height: 0,
812 };
813 let default_shell = std::env::var("SHELL").unwrap_or_else(|_| "sh".into());
814 let command = cmd_str.clone().unwrap_or_else(|| default_shell.clone());
815 let cmd_args: Vec<&str> = if let Some(ref c) = cmd_str {
816 vec!["sh", "-c", c.as_str()]
817 } else {
818 vec![default_shell.as_str()]
819 };
820 let env_refs: Vec<(String, String)> = env.clone();
821
822 let authorization = ctx.invocation_authorization_for("term.spawn")?;
823 let pty_result = match authorization.execution_boundary() {
824 crate::permission::ExecutionBoundary::Sandboxed => {
825 let sandbox = ctx.sandbox.as_ref().ok_or_else(|| {
826 RuntimeError::ToolFailed(
827 "term.spawn: sandbox unavailable for controlled execution".into(),
828 )
829 })?;
830 sandbox
831 .spawn_pty(&cmd_args, &env_refs, &cwd, pty_size, authorization)
832 .await
833 .map_err(|error| error.into_runtime("term.spawn"))?
834 }
835 crate::permission::ExecutionBoundary::Direct => {
836 spawn_pty_direct(&cmd_args, &env_refs, &cwd, pty_size)?
837 }
838 };
839
840 let (handle, entry) = registry.spawn_entry(
841 rows,
842 cols,
843 session_id,
844 session_dir,
845 pty_result,
846 ctx.stream_tx.clone(),
847 ctx.call_intent
848 .as_ref()
849 .map(|intent| intent.as_str().to_owned())
850 .or(cmd_str)
851 .unwrap_or_else(|| "terminal".into()),
852 command,
853 ctx.call_intent.clone(),
854 ctx.tool_use_id.clone(),
855 {
856 let tc = ctx.cancel.clone();
857 tc.child_token()
858 },
859 ctx.events.clone(),
860 ctx.flow_run_id.as_ref().map(|r| r.0.to_string()),
861 )?;
862
863 let state = entry.current_state();
864 let text = {
865 let parser = entry.parser.lock().expect("parser poisoned");
866 parser.screen().contents()
867 };
868 Ok(Value::Struct(vec![
869 ("handle".into(), Value::Str(handle.to_string())),
870 ("state".into(), state_to_value(&state)),
871 ("rows".into(), Value::Int(rows as i64)),
872 ("cols".into(), Value::Int(cols as i64)),
873 ("text".into(), Value::Str(text)),
874 ]))
875}
876
877fn spawn_pty_direct(
878 cmd: &[&str],
879 env: &[(String, String)],
880 cwd: &Path,
881 pty_size: portable_pty::PtySize,
882) -> Result<PtySpawnResult, RuntimeError> {
883 let pty_system = portable_pty::native_pty_system();
884 let pair = pty_system
885 .openpty(pty_size)
886 .map_err(|e| RuntimeError::ToolFailed(format!("openpty: {e}")))?;
887 let mut builder = portable_pty::CommandBuilder::new(cmd[0]);
888 for arg in &cmd[1..] {
889 builder.arg(arg);
890 }
891 builder.cwd(cwd);
892 for (k, v) in env {
893 builder.env(k, v);
894 }
895 let child = pair
896 .slave
897 .spawn_command(builder)
898 .map_err(|e| RuntimeError::ToolFailed(format!("pty spawn: {e}")))?;
899 crate::sandbox::complete_pty_spawn(child, pair.master, None)
900}
901
902fn cell_text(screen: &TerminalScreen, row: u16, col: u16) -> String {
903 let idx = (row as usize) * (screen.cols as usize) + (col as usize);
904 match screen.cells.get(idx) {
905 Some(cell) if cell.wide_continuation => String::new(),
906 Some(cell) if cell.chars.is_empty() => " ".to_string(),
907 Some(cell) => cell.chars.clone(),
908 None => " ".to_string(),
909 }
910}
911
912fn find_pattern_on_screen(screen: &TerminalScreen, pattern: &str) -> Option<(u16, u16)> {
913 for r in 0..screen.rows {
914 let row_text: String = (0..screen.cols)
915 .map(|c| cell_text(screen, r, c))
916 .collect::<String>()
917 .trim_end()
918 .to_string();
919 if let Some(pos) = row_text.find(pattern) {
920 return Some((r, pos as u16));
921 }
922 }
923 None
924}
925
926impl crate::watch::Watchable for TermEntry {
927 fn watch_output(
928 self: std::sync::Arc<Self>,
929 pattern: String,
930 cancel: tokio_util::sync::CancellationToken,
931 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = crate::watch::WatchResult> + Send>>
932 {
933 let stream_tx = self.stream_tx.clone();
934 let state = self.state.clone();
935 let parser = self.parser.clone();
936 Box::pin(async move {
937 let mut rx = stream_tx.subscribe();
940 {
941 let st = state.lock().unwrap().clone();
942 if !st.is_running() {
943 let p = parser.lock().unwrap();
944 let screen = snapshot_screen(&p);
945 if let Some((r, c)) = find_pattern_on_screen(&screen, &pattern) {
946 return crate::watch::WatchResult::Matched {
947 row: Some(r),
948 col: Some(c),
949 text: pattern,
950 };
951 }
952 return crate::watch::WatchResult::SourceExited;
953 }
954 }
955 loop {
956 tokio::select! {
957 _ = cancel.cancelled() => return crate::watch::WatchResult::Cancelled,
958 result = rx.recv() => match result {
959 Ok(crate::tools::term::TermStreamEvent::Chunk { screen, .. }) => {
960 if let Some((r, c)) = find_pattern_on_screen(&screen, &pattern) {
961 return crate::watch::WatchResult::Matched {
962 row: Some(r),
963 col: Some(c),
964 text: pattern,
965 };
966 }
967 }
968 _ => return crate::watch::WatchResult::SourceExited,
969 }
970 }
971 }
972 })
973 }
974}
975
976fn screen_to_value(screen: &TerminalScreen) -> Value {
977 let cells: Vec<Value> = screen
978 .cells
979 .iter()
980 .map(|c| {
981 Value::Struct(vec![
982 ("chars".into(), Value::Str(c.chars.clone())),
983 ("fg".into(), color_to_value(c.fg)),
984 ("bg".into(), color_to_value(c.bg)),
985 ("bold".into(), Value::Bool(c.bold)),
986 ("italic".into(), Value::Bool(c.italic)),
987 ("underline".into(), Value::Bool(c.underline)),
988 ("inverse".into(), Value::Bool(c.inverse)),
989 ("dim".into(), Value::Bool(c.dim)),
990 ("wide".into(), Value::Bool(c.wide)),
991 ("wide_continuation".into(), Value::Bool(c.wide_continuation)),
992 ])
993 })
994 .collect();
995 Value::Struct(vec![
996 ("rows".into(), Value::Int(screen.rows as i64)),
997 ("cols".into(), Value::Int(screen.cols as i64)),
998 ("cells".into(), Value::List(cells)),
999 (
1000 "cursor".into(),
1001 match screen.cursor {
1002 Some((r, c)) => Value::Struct(vec![
1003 ("row".into(), Value::Int(r as i64)),
1004 ("col".into(), Value::Int(c as i64)),
1005 ]),
1006 None => Value::Unit,
1007 },
1008 ),
1009 ("alt_screen".into(), Value::Bool(screen.alt_screen)),
1010 ])
1011}
1012
1013fn color_to_value(c: TerminalColor) -> Value {
1014 match c {
1015 TerminalColor::Default => Value::Str("default".into()),
1016 TerminalColor::Idx(i) => Value::Int(i as i64),
1017 TerminalColor::Rgb(r, g, b) => Value::Struct(vec![
1018 ("r".into(), Value::Int(r as i64)),
1019 ("g".into(), Value::Int(g as i64)),
1020 ("b".into(), Value::Int(b as i64)),
1021 ]),
1022 }
1023}
1024
1025fn state_to_value(state: &TermState) -> Value {
1026 match state {
1027 TermState::Running { pid, started_at } => Value::Struct(vec![
1028 ("kind".into(), Value::Str("running".into())),
1029 ("pid".into(), Value::Int(*pid as i64)),
1030 ("started_at".into(), Value::Int(*started_at as i64)),
1031 ]),
1032 TermState::Exited {
1033 exit_code,
1034 ended_at,
1035 } => Value::Struct(vec![
1036 ("kind".into(), Value::Str("exited".into())),
1037 (
1038 "exit_code".into(),
1039 exit_code
1040 .map(|c| Value::Int(c as i64))
1041 .unwrap_or(Value::Unit),
1042 ),
1043 ("ended_at".into(), Value::Int(*ended_at as i64)),
1044 ]),
1045 TermState::Failed { error, ended_at } => Value::Struct(vec![
1046 ("kind".into(), Value::Str("failed".into())),
1047 ("error".into(), Value::Str(error.clone())),
1048 ("ended_at".into(), Value::Int(*ended_at as i64)),
1049 ]),
1050 TermState::Killed { ended_at } => Value::Struct(vec![
1051 ("kind".into(), Value::Str("killed".into())),
1052 ("ended_at".into(), Value::Int(*ended_at as i64)),
1053 ]),
1054 }
1055}
1056
1057pub struct TermInput;
1058impl Tool for TermInput {
1059 fn name(&self) -> &str {
1060 "term.input"
1061 }
1062 fn tier(&self) -> Tier {
1063 Tier::Four
1064 }
1065 fn description(&self) -> Option<&str> {
1066 Some(
1067 "Send input to a terminal's PTY. Use `text` for literal text, `key` for\nspecial keys (enter, tab, esc, backspace, up, down, left, right, ctrl+c,\nctrl+d, ctrl+z), or `mouse` for mouse events. Use key: \"enter\" to\nsubmit a command, not text: \"\\r\". Mouse actions: click, double_click,\nlong_press, drag, press, release, move, scroll_up, scroll_down.\nUse term.find to locate text on screen before clicking.",
1068 )
1069 }
1070 fn input_schema(&self) -> serde_json::Value {
1071 serde_json::json!({
1072 "type": "object",
1073 "properties": {
1074 "handle": {"type": "string"},
1075 "text": {"type": "string", "description": "Literal text to write. Do NOT use \\r or \\n here — use key:\"enter\" instead."},
1076 "key": {"type": "string", "enum": ["enter", "tab", "esc", "backspace", "up", "down", "left", "right", "ctrl+c", "ctrl+d", "ctrl+z"]},
1077 "mouse": {
1078 "type": "object",
1079 "properties": {
1080 "action": {
1081 "type": "string",
1082 "enum": ["click", "double_click", "long_press", "drag", "press", "release", "move", "scroll_up", "scroll_down"],
1083 "description": "click=press+release, double_click=two clicks, long_press=press+500ms+release, drag=press+move+release (needs x2,y2), move=hover, scroll_up/down=wheel"
1084 },
1085 "button": {"type": "string", "enum": ["left", "right", "middle"], "default": "left"},
1086 "x": {"type": "integer", "description": "Column (0-indexed, same as term.find col)"},
1087 "y": {"type": "integer", "description": "Row (0-indexed, same as term.find row)"},
1088 "x2": {"type": "integer", "description": "End column for drag"},
1089 "y2": {"type": "integer", "description": "End row for drag"}
1090 },
1091 "required": ["action", "x", "y"],
1092 "description": "Mouse event. Coordinates are 0-indexed — pass term.find (col,row) directly."
1093 }
1094 },
1095 "required": ["handle"]
1096 })
1097 }
1098 fn call<'a>(
1099 &'a self,
1100 args: crate::tool::ToolArgs,
1101 ctx: &'a crate::tool::ToolCtx,
1102 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1103 Box::pin(async move {
1104 let handle = extract_string(&args, "handle", 0)?;
1105 let text = extract_optional_string(&args, "text").unwrap_or_default();
1106 let key = extract_optional_string(&args, "key");
1107 let mouse = args.named("mouse");
1108 let registry = ctx.term_registry.clone().ok_or_else(|| {
1109 RuntimeError::ToolFailed("term.input: registry not available".into())
1110 })?;
1111 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1112 let entry = registry.lookup(&handle, &session_id)?;
1113
1114 let mut steps: Vec<(Vec<u8>, std::time::Duration)> = Vec::new();
1116 let mut first = text.into_bytes();
1117 if let Some(k) = &key {
1118 first.extend_from_slice(&key_to_bytes(k));
1119 }
1120 if !first.is_empty() {
1121 steps.push((first, std::time::Duration::ZERO));
1122 }
1123 if let Some(m) = &mouse {
1124 steps.extend(mouse_to_steps(m)?);
1125 }
1126 if steps.is_empty() {
1127 return Err(RuntimeError::ToolFailed(
1128 "term.input: provide at least one of `text`, `key`, or `mouse`".into(),
1129 ));
1130 }
1131
1132 let mut total = 0usize;
1133 for (payload, delay) in steps {
1134 if !payload.is_empty() {
1135 let mut w = entry.writer.lock().expect("writer poisoned");
1136 w.write_all(&payload)
1137 .map_err(|e| RuntimeError::ToolFailed(format!("term.input write: {e}")))?;
1138 total += payload.len();
1139 drop(w);
1140 }
1141 if !delay.is_zero() {
1142 tokio::time::sleep(delay).await;
1143 }
1144 }
1145 Ok(Value::Struct(vec![
1146 ("ok".into(), Value::Bool(true)),
1147 ("bytes_written".into(), Value::Int(total as i64)),
1148 ]))
1149 })
1150 }
1151}
1152
1153fn key_to_bytes(key: &str) -> Vec<u8> {
1154 match key {
1155 "enter" => vec![b'\r'],
1156 "tab" => vec![b'\t'],
1157 "esc" => vec![0x1b],
1158 "backspace" => vec![0x7f],
1159 "up" => vec![0x1b, b'[', b'A'],
1160 "down" => vec![0x1b, b'[', b'B'],
1161 "right" => vec![0x1b, b'[', b'C'],
1162 "left" => vec![0x1b, b'[', b'D'],
1163 "ctrl+c" => vec![0x03],
1164 "ctrl+d" => vec![0x04],
1165 "ctrl+z" => vec![0x1a],
1166 _ => Vec::new(),
1167 }
1168}
1169
1170fn sgr(btn: u32, action: &str, col0: i64, row0: i64) -> Vec<u8> {
1173 let x = col0 + 1;
1174 let y = row0 + 1;
1175 match action {
1176 "press" => format!("\x1b[<{btn};{x};{y}M").into_bytes(),
1177 "release" => format!("\x1b[<{btn};{x};{y}m").into_bytes(),
1178 "move" => format!("\x1b[<{btn};{x};{y}M", btn = btn + 32).into_bytes(),
1179 _ => Vec::new(),
1180 }
1181}
1182
1183fn mouse_to_steps(val: &Value) -> Result<Vec<(Vec<u8>, std::time::Duration)>, RuntimeError> {
1186 let fields = match val {
1187 Value::Struct(f) => f,
1188 _ => {
1189 return Err(RuntimeError::ToolFailed(
1190 "term.input: mouse must be an object".into(),
1191 ));
1192 }
1193 };
1194 let get_str = |name: &str| -> Result<&str, RuntimeError> {
1195 fields
1196 .iter()
1197 .find(|(k, _)| k == name)
1198 .and_then(|(_, v)| {
1199 if let Value::Str(s) = v {
1200 Some(s.as_str())
1201 } else {
1202 None
1203 }
1204 })
1205 .ok_or_else(|| RuntimeError::ToolFailed(format!("term.input: mouse.{name} missing")))
1206 };
1207 let get_int = |name: &str| -> Result<i64, RuntimeError> {
1208 fields
1209 .iter()
1210 .find(|(k, _)| k == name)
1211 .and_then(|(_, v)| {
1212 if let Value::Int(i) = v {
1213 Some(*i)
1214 } else {
1215 None
1216 }
1217 })
1218 .ok_or_else(|| RuntimeError::ToolFailed(format!("term.input: mouse.{name} missing")))
1219 };
1220 let get_opt_int = |name: &str| -> Option<i64> {
1221 fields.iter().find(|(k, _)| k == name).and_then(|(_, v)| {
1222 if let Value::Int(i) = v {
1223 Some(*i)
1224 } else {
1225 None
1226 }
1227 })
1228 };
1229
1230 let action = get_str("action")?;
1231 let button_str = get_opt_str(fields, "button").unwrap_or("left");
1232 let x = get_int("x")?;
1233 let y = get_int("y")?;
1234 let btn: u32 = match button_str {
1235 "left" => 0,
1236 "middle" => 1,
1237 "right" => 2,
1238 _ => {
1239 return Err(RuntimeError::ToolFailed(format!(
1240 "term.input: mouse.button must be left/right/middle, got {button_str}"
1241 )));
1242 }
1243 };
1244
1245 let z = std::time::Duration::ZERO;
1246 let gap = std::time::Duration::from_millis(50);
1247 let long = std::time::Duration::from_millis(500);
1248
1249 match action {
1250 "press" => Ok(vec![(sgr(btn, "press", x, y), z)]),
1251 "release" => Ok(vec![(sgr(btn, "release", x, y), z)]),
1252 "click" => Ok(vec![
1253 (sgr(btn, "press", x, y), z),
1254 (sgr(btn, "release", x, y), z),
1255 ]),
1256 "double_click" => Ok(vec![
1257 (sgr(btn, "press", x, y), z),
1258 (sgr(btn, "release", x, y), gap),
1259 (sgr(btn, "press", x, y), z),
1260 (sgr(btn, "release", x, y), z),
1261 ]),
1262 "long_press" => Ok(vec![
1263 (sgr(btn, "press", x, y), long),
1264 (sgr(btn, "release", x, y), z),
1265 ]),
1266 "drag" => {
1267 let x2 = get_opt_int("x2").ok_or_else(|| {
1268 RuntimeError::ToolFailed("term.input: mouse.drag requires x2".into())
1269 })?;
1270 let y2 = get_opt_int("y2").ok_or_else(|| {
1271 RuntimeError::ToolFailed("term.input: mouse.drag requires y2".into())
1272 })?;
1273 let mut steps = vec![(sgr(btn, "press", x, y), z)];
1274 let dx = x2 - x;
1275 let dy = y2 - y;
1276 let n = dx.unsigned_abs().max(dy.unsigned_abs());
1277 for i in 1..=n {
1278 let cx = x + dx * i as i64 / n as i64;
1279 let cy = y + dy * i as i64 / n as i64;
1280 steps.push((sgr(btn, "move", cx, cy), z));
1281 }
1282 steps.push((sgr(btn, "release", x2, y2), z));
1283 Ok(steps)
1284 }
1285 "move" => Ok(vec![(sgr(0, "move", x, y), z)]),
1286 "scroll_up" => Ok(vec![(
1287 format!("\x1b[<64;{};{}M", x + 1, y + 1).into_bytes(),
1288 z,
1289 )]),
1290 "scroll_down" => Ok(vec![(
1291 format!("\x1b[<65;{};{}M", x + 1, y + 1).into_bytes(),
1292 z,
1293 )]),
1294 _ => Err(RuntimeError::ToolFailed(format!(
1295 "term.input: mouse.action must be click/double_click/long_press/drag/press/release/move/scroll_up/scroll_down, got {action}"
1296 ))),
1297 }
1298}
1299
1300fn get_opt_str<'a>(fields: &'a [(String, Value)], name: &'a str) -> Option<&'a str> {
1301 fields.iter().find(|(k, _)| k == name).and_then(|(_, v)| {
1302 if let Value::Str(s) = v {
1303 Some(s.as_str())
1304 } else {
1305 None
1306 }
1307 })
1308}
1309
1310pub struct TermCapture;
1311impl Tool for TermCapture {
1312 fn name(&self) -> &str {
1313 "term.capture"
1314 }
1315 fn tier(&self) -> Tier {
1316 Tier::Four
1317 }
1318 fn description(&self) -> Option<&str> {
1319 Some(
1320 "Read the terminal screen. Default returns plain text (format: \"text\").\n\
1321 Use start_row/end_row and start_col/end_col to read a rectangular region.\n\n\
1322 Best practices:\n\
1323 - After sending a command, capture to see the result.\n\
1324 - Use start_row/end_row to read only the relevant part (e.g. last 10 rows).\n\
1325 - Use start_col/end_col to read a column range (e.g. skip line numbers).\n\
1326 - format: \"screen\" returns full cell data with colors/styles.\n\
1327 - Default format: \"text\" is sufficient for most cases.",
1328 )
1329 }
1330 fn input_schema(&self) -> serde_json::Value {
1331 serde_json::json!({
1332 "type": "object",
1333 "properties": {
1334 "handle": {"type": "string"},
1335 "format": {"type": "string", "enum": ["text", "screen"], "default": "text"},
1336 "start_row": {"type": "integer", "default": 0, "description": "Start row (0-based). Default 0."},
1337 "end_row": {"type": "integer", "description": "End row (exclusive). Default: full height."},
1338 "start_col": {"type": "integer", "default": 0, "description": "Start column (0-based). Default 0."},
1339 "end_col": {"type": "integer", "description": "End column (exclusive). Default: full width."}
1340 },
1341 "required": ["handle"]
1342 })
1343 }
1344 fn call<'a>(
1345 &'a self,
1346 args: crate::tool::ToolArgs,
1347 ctx: &'a crate::tool::ToolCtx,
1348 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1349 Box::pin(async move {
1350 let handle = extract_string(&args, "handle", 0)?;
1351 let format = extract_optional_string(&args, "format").unwrap_or_else(|| "text".into());
1352 let registry = ctx.term_registry.clone().ok_or_else(|| {
1353 RuntimeError::ToolFailed("term.capture: registry not available".into())
1354 })?;
1355 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1356 let entry = registry.lookup(&handle, &session_id)?;
1357 let screen = entry.snapshot();
1358 let start_row = extract_optional_int(&args, "start_row")
1359 .unwrap_or(0)
1360 .clamp(0, screen.rows as i64) as u16;
1361 let requested_end_row = extract_optional_int(&args, "end_row")
1362 .unwrap_or(screen.rows as i64)
1363 .clamp(start_row as i64, screen.rows as i64)
1364 as u16;
1365 let start_col = extract_optional_int(&args, "start_col")
1366 .unwrap_or(0)
1367 .clamp(0, screen.cols as i64) as u16;
1368 let end_col = extract_optional_int(&args, "end_col")
1369 .unwrap_or(screen.cols as i64)
1370 .clamp(start_col as i64, screen.cols as i64) as u16;
1371 let width = usize::from(end_col.saturating_sub(start_col));
1372 let max_rows_by_bytes = ctx
1373 .tool_output_budget
1374 .max_bytes
1375 .checked_div(width)
1376 .unwrap_or(ctx.tool_output_budget.max_lines);
1377 let max_rows = ctx
1378 .tool_output_budget
1379 .max_lines
1380 .min(max_rows_by_bytes.max(1))
1381 .min(u16::MAX as usize) as u16;
1382 let end_row = if format == "screen" {
1383 requested_end_row.min(start_row.saturating_add(max_rows))
1384 } else {
1385 requested_end_row
1386 };
1387 let state = entry.current_state();
1388 let mut fields = vec![
1389 ("handle".into(), Value::Str(handle.clone())),
1390 ("state".into(), state_to_value(&state)),
1391 ("rows".into(), Value::Int((end_row - start_row) as i64)),
1392 ("cols".into(), Value::Int((end_col - start_col) as i64)),
1393 ];
1394 if format == "screen" {
1395 fields.push((
1396 "continuation".into(),
1397 Value::Struct(vec![
1398 ("type".into(), Value::Str("TerminalArea".into())),
1399 ("next_row".into(), Value::Int(end_row as i64)),
1400 ("start_col".into(), Value::Int(start_col as i64)),
1401 ("end_col".into(), Value::Int(end_col as i64)),
1402 ("has_more".into(), Value::Bool(end_row < screen.rows)),
1403 ]),
1404 ));
1405 let sub_rows = end_row - start_row;
1406 let sub_cols = end_col - start_col;
1407 let sub_cells: Vec<TerminalCell> = (start_row..end_row)
1408 .flat_map(|r| {
1409 (start_col..end_col)
1410 .map(|c| {
1411 screen
1412 .cells
1413 .get((r as usize) * (screen.cols as usize) + (c as usize))
1414 .cloned()
1415 .unwrap_or_default()
1416 })
1417 .collect::<Vec<_>>()
1418 })
1419 .collect();
1420 let cursor = screen.cursor.and_then(|(row, col)| {
1421 if (start_row..end_row).contains(&row) && (start_col..end_col).contains(&col) {
1422 Some((row - start_row, col - start_col))
1423 } else {
1424 None
1425 }
1426 });
1427 let partial_screen = TerminalScreen {
1428 rows: sub_rows,
1429 cols: sub_cols,
1430 cells: sub_cells,
1431 cursor,
1432 alt_screen: screen.alt_screen,
1433 };
1434 fields.push(("screen".into(), screen_to_value(&partial_screen)));
1435 } else {
1436 let text = (start_row..end_row)
1437 .map(|r| {
1438 let row_text: String = (start_col..end_col)
1439 .map(|c| cell_text(&screen, r, c))
1440 .collect();
1441 row_text.trim_end().to_string()
1442 })
1443 .collect::<Vec<_>>()
1444 .join("\n");
1445 let cut =
1446 crate::tools::tool_output::bounded_text_prefix(&text, ctx.tool_output_budget);
1447 if cut == text.len() {
1448 fields.push(("text".into(), Value::Str(text)));
1449 } else {
1450 let output_id = ctx
1451 .output_store
1452 .as_deref()
1453 .and_then(|store| store.register("term_capture", &text))
1454 .ok_or_else(|| {
1455 RuntimeError::ToolFailed(
1456 "term.capture: output exceeded budget, but no output_id is available for output.read".into(),
1457 )
1458 })?;
1459 let total_lines = text.split_inclusive('\n').count();
1460 fields.push(("content".into(), Value::Str(text[..cut].to_string())));
1461 fields.push(("output_id".into(), Value::Str(output_id)));
1462 fields.push(("total_lines".into(), Value::Int(total_lines as i64)));
1463 fields.push(("total_bytes".into(), Value::Int(text.len() as i64)));
1464 fields.push((
1465 "next".into(),
1466 Value::Struct(vec![
1467 ("mode".into(), Value::Str("bytes".into())),
1468 ("offset".into(), Value::Int(cut as i64)),
1469 ("has_more".into(), Value::Bool(cut < text.len())),
1470 ]),
1471 ));
1472 }
1473 }
1474 Ok(Value::Struct(fields))
1475 })
1476 }
1477}
1478
1479pub struct TermFind;
1480impl Tool for TermFind {
1481 fn name(&self) -> &str {
1482 "term.find"
1483 }
1484 fn tier(&self) -> Tier {
1485 Tier::Four
1486 }
1487 fn description(&self) -> Option<&str> {
1488 Some(
1489 "Search terminal screen for text and/or style. Returns list of matches.\n\n\
1490 - pattern: substring to search for (case-sensitive). Omit for style-only search.\n\
1491 - style: optional filter. Only cells matching ALL specified style fields count.\n\
1492 - Combine both: find red 'error' text, bold prompts, etc.\n\n\
1493 Style fields: bold, italic, underline, inverse, dim, fg, bg.\n\
1494 Colors: name (\"red\") or {r,g,b} struct (discover via term.capture format=\"screen\").\n\n\
1495 Returns: { matches: [{row, col, text}], count: N }",
1496 )
1497 }
1498 fn input_schema(&self) -> serde_json::Value {
1499 serde_json::json!({
1500 "type": "object",
1501 "properties": {
1502 "handle": {"type": "string"},
1503 "pattern": {"type": "string", "description": "Substring to search for. Omit for style-only search."},
1504 "style": {
1505 "type": "object",
1506 "properties": {
1507 "bold": {"type": "boolean"},
1508 "italic": {"type": "boolean"},
1509 "underline": {"type": "boolean"},
1510 "inverse": {"type": "boolean"},
1511 "dim": {"type": "boolean"},
1512 "fg": {"type": "string", "description": "Color name or {r,g,b} struct"},
1513 "bg": {"type": "string", "description": "Color name or {r,g,b} struct"}
1514 },
1515 "description": "Optional style filter. All specified fields must match."
1516 },
1517 "start_row": {"type": "integer", "default": 0},
1518 "end_row": {"type": "integer", "description": "Exclusive. Default: full height."}
1519 },
1520 "required": ["handle"]
1521 })
1522 }
1523 fn call<'a>(
1524 &'a self,
1525 args: crate::tool::ToolArgs,
1526 ctx: &'a crate::tool::ToolCtx,
1527 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1528 Box::pin(async move {
1529 let handle = extract_string(&args, "handle", 0)?;
1530 let pattern = extract_optional_string(&args, "pattern");
1531 let style_filter = parse_style_filter(&args)?;
1532 if pattern.is_none() && style_filter.is_none() {
1533 return Err(RuntimeError::ToolFailed(
1534 "term.find: provide at least pattern or style".into(),
1535 ));
1536 }
1537 let registry = ctx.term_registry.clone().ok_or_else(|| {
1538 RuntimeError::ToolFailed("term.find: registry not available".into())
1539 })?;
1540 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1541 let entry = registry.lookup(&handle, &session_id)?;
1542 let screen = entry.snapshot();
1543 let start_row = extract_optional_int(&args, "start_row")
1544 .unwrap_or(0)
1545 .clamp(0, screen.rows as i64) as u16;
1546 let end_row = extract_optional_int(&args, "end_row")
1547 .unwrap_or(screen.rows as i64)
1548 .clamp(start_row as i64, screen.rows as i64) as u16;
1549
1550 let mut matches = Vec::new();
1551 for r in start_row..end_row {
1552 let row_cells: Vec<&TerminalCell> =
1553 (0..screen.cols).map(|c| cell_ref(&screen, r, c)).collect();
1554 let row_text: String = row_cells
1555 .iter()
1556 .map(|c| {
1557 if c.wide_continuation {
1558 ""
1559 } else {
1560 c.chars.as_str()
1561 }
1562 })
1563 .collect::<String>();
1564 let row_text_trimmed = row_text.trim_end();
1565
1566 if let Some(ref pat) = pattern {
1567 let mut start = 0;
1568 while let Some(pos) = row_text_trimmed[start..].find(pat) {
1569 let abs_col = start + pos;
1570 let style_ok = style_filter
1571 .as_ref()
1572 .map(|sf| sf.matches(row_cells.get(abs_col).copied()))
1573 .unwrap_or(true);
1574 if style_ok {
1575 let matched_text = pat.clone();
1576 matches.push(Value::Struct(vec![
1577 ("row".into(), Value::Int(r as i64)),
1578 ("col".into(), Value::Int(abs_col as i64)),
1579 ("text".into(), Value::Str(matched_text)),
1580 ]));
1581 }
1582 start += pos + pat.len();
1583 if start >= row_text_trimmed.len() {
1584 break;
1585 }
1586 }
1587 } else if let Some(ref sf) = style_filter {
1588 let mut col = 0usize;
1589 while col < screen.cols as usize {
1590 if sf.matches(row_cells.get(col).copied())
1591 && !row_cells[col].chars.is_empty()
1592 {
1593 let start_col = col;
1594 let mut text = String::new();
1595 while col < screen.cols as usize
1596 && sf.matches(row_cells.get(col).copied())
1597 && !row_cells[col].wide_continuation
1598 {
1599 text.push_str(&row_cells[col].chars);
1600 col += 1;
1601 }
1602 if !text.trim().is_empty() {
1603 matches.push(Value::Struct(vec![
1604 ("row".into(), Value::Int(r as i64)),
1605 ("col".into(), Value::Int(start_col as i64)),
1606 ("text".into(), Value::Str(text)),
1607 ]));
1608 }
1609 } else {
1610 col += 1;
1611 }
1612 }
1613 }
1614 }
1615 let count = matches.len() as i64;
1616 Ok(Value::Struct(vec![
1617 ("matches".into(), Value::List(matches)),
1618 ("count".into(), Value::Int(count)),
1619 ]))
1620 })
1621 }
1622}
1623
1624fn cell_ref(screen: &TerminalScreen, row: u16, col: u16) -> &TerminalCell {
1625 let idx = (row as usize) * (screen.cols as usize) + (col as usize);
1626 screen.cells.get(idx).unwrap_or(&DEFAULT_CELL)
1627}
1628
1629static DEFAULT_CELL: TerminalCell = TerminalCell {
1630 chars: String::new(),
1631 fg: TerminalColor::Default,
1632 bg: TerminalColor::Default,
1633 bold: false,
1634 italic: false,
1635 underline: false,
1636 inverse: false,
1637 dim: false,
1638 wide: false,
1639 wide_continuation: false,
1640};
1641
1642struct StyleFilter {
1643 bold: Option<bool>,
1644 italic: Option<bool>,
1645 underline: Option<bool>,
1646 inverse: Option<bool>,
1647 dim: Option<bool>,
1648 fg: Option<TerminalColor>,
1649 bg: Option<TerminalColor>,
1650}
1651
1652impl StyleFilter {
1653 fn matches(&self, cell: Option<&TerminalCell>) -> bool {
1654 let Some(cell) = cell else {
1655 return false;
1656 };
1657 if self.bold == Some(true) && !cell.bold {
1658 return false;
1659 }
1660 if self.bold == Some(false) && cell.bold {
1661 return false;
1662 }
1663 if self.italic == Some(true) && !cell.italic {
1664 return false;
1665 }
1666 if self.italic == Some(false) && cell.italic {
1667 return false;
1668 }
1669 if self.underline == Some(true) && !cell.underline {
1670 return false;
1671 }
1672 if self.underline == Some(false) && cell.underline {
1673 return false;
1674 }
1675 if self.inverse == Some(true) && !cell.inverse {
1676 return false;
1677 }
1678 if self.inverse == Some(false) && cell.inverse {
1679 return false;
1680 }
1681 if self.dim == Some(true) && !cell.dim {
1682 return false;
1683 }
1684 if self.dim == Some(false) && cell.dim {
1685 return false;
1686 }
1687 if let Some(fg) = &self.fg {
1688 if !color_matches(fg, &cell.fg) {
1689 return false;
1690 }
1691 }
1692 if let Some(bg) = &self.bg {
1693 if !color_matches(bg, &cell.bg) {
1694 return false;
1695 }
1696 }
1697 true
1698 }
1699}
1700
1701fn parse_style_filter(args: &crate::tool::ToolArgs) -> Result<Option<StyleFilter>, RuntimeError> {
1702 let style = match args.named("style") {
1703 Some(v) => v,
1704 None => return Ok(None),
1705 };
1706 Ok(Some(StyleFilter {
1707 bold: get_optional_bool(style, "bold"),
1708 italic: get_optional_bool(style, "italic"),
1709 underline: get_optional_bool(style, "underline"),
1710 inverse: get_optional_bool(style, "inverse"),
1711 dim: get_optional_bool(style, "dim"),
1712 fg: get_optional_color(style, "fg")?,
1713 bg: get_optional_color(style, "bg")?,
1714 }))
1715}
1716
1717fn get_optional_bool(style: &Value, field: &str) -> Option<bool> {
1718 match style.field(field) {
1719 Some(Value::Bool(b)) => Some(*b),
1720 _ => None,
1721 }
1722}
1723
1724fn get_optional_color(style: &Value, field: &str) -> Result<Option<TerminalColor>, RuntimeError> {
1725 match style.field(field) {
1726 Some(v) => parse_color(v).map(Some).ok_or_else(|| {
1727 RuntimeError::ToolFailed(format!(
1728 "term.find: invalid color for '{field}' — use name (\"red\") or {{r,g,b}} struct"
1729 ))
1730 }),
1731 None => Ok(None),
1732 }
1733}
1734
1735fn parse_color(val: &Value) -> Option<TerminalColor> {
1736 match val {
1737 Value::Str(name) => color_name_to_terminal(name),
1738 Value::Struct(fields) => {
1739 let r = fields
1740 .iter()
1741 .find(|(k, _)| k == "r")
1742 .and_then(|(_, v)| match v {
1743 Value::Int(n) => Some(*n),
1744 _ => None,
1745 })?;
1746 let g = fields
1747 .iter()
1748 .find(|(k, _)| k == "g")
1749 .and_then(|(_, v)| match v {
1750 Value::Int(n) => Some(*n),
1751 _ => None,
1752 })?;
1753 let b = fields
1754 .iter()
1755 .find(|(k, _)| k == "b")
1756 .and_then(|(_, v)| match v {
1757 Value::Int(n) => Some(*n),
1758 _ => None,
1759 })?;
1760 Some(TerminalColor::Rgb(r as u8, g as u8, b as u8))
1761 }
1762 _ => None,
1763 }
1764}
1765
1766fn color_name_to_terminal(name: &str) -> Option<TerminalColor> {
1767 let idx = match name {
1768 "default" => return Some(TerminalColor::Default),
1769 "black" => 0,
1770 "red" => 1,
1771 "green" => 2,
1772 "yellow" => 3,
1773 "blue" => 4,
1774 "magenta" => 5,
1775 "cyan" => 6,
1776 "white" => 7,
1777 _ => return None,
1778 };
1779 Some(TerminalColor::Idx(idx))
1780}
1781
1782fn color_matches(desired: &TerminalColor, actual: &TerminalColor) -> bool {
1783 match (desired, actual) {
1784 (TerminalColor::Default, TerminalColor::Default) => true,
1785 (TerminalColor::Idx(d), TerminalColor::Idx(a)) => *d == *a || (*d < 8 && *a == *d + 8),
1786 (TerminalColor::Rgb(dr, dg, db), TerminalColor::Rgb(ar, ag, ab)) => {
1787 dr == ar && dg == ag && db == ab
1788 }
1789 _ => false,
1790 }
1791}
1792
1793pub struct TermResize;
1794impl Tool for TermResize {
1795 fn name(&self) -> &str {
1796 "term.resize"
1797 }
1798 fn tier(&self) -> Tier {
1799 Tier::Four
1800 }
1801 fn description(&self) -> Option<&str> {
1802 Some("Resize a terminal's PTY dimensions. Sends SIGWINCH to the child process.")
1803 }
1804 fn input_schema(&self) -> serde_json::Value {
1805 serde_json::json!({
1806 "type": "object",
1807 "properties": {
1808 "handle": {"type": "string"},
1809 "rows": {"type": "integer"},
1810 "cols": {"type": "integer"}
1811 },
1812 "required": ["handle", "rows", "cols"]
1813 })
1814 }
1815 fn call<'a>(
1816 &'a self,
1817 args: crate::tool::ToolArgs,
1818 ctx: &'a crate::tool::ToolCtx,
1819 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1820 Box::pin(async move {
1821 let handle = extract_string(&args, "handle", 0)?;
1822 let rows = extract_optional_int(&args, "rows")
1823 .ok_or_else(|| RuntimeError::MissingArg("rows".into()))?
1824 as u16;
1825 let cols = extract_optional_int(&args, "cols")
1826 .ok_or_else(|| RuntimeError::MissingArg("cols".into()))?
1827 as u16;
1828 let registry = ctx.term_registry.clone().ok_or_else(|| {
1829 RuntimeError::ToolFailed("term.resize: registry not available".into())
1830 })?;
1831 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1832 let entry = registry.lookup(&handle, &session_id)?;
1833 entry.resize(rows, cols)?;
1834 Ok(Value::Struct(vec![
1835 ("ok".into(), Value::Bool(true)),
1836 ("rows".into(), Value::Int(rows as i64)),
1837 ("cols".into(), Value::Int(cols as i64)),
1838 ]))
1839 })
1840 }
1841}
1842
1843pub struct TermKill;
1844impl Tool for TermKill {
1845 fn name(&self) -> &str {
1846 "term.kill"
1847 }
1848 fn tier(&self) -> Tier {
1849 Tier::Four
1850 }
1851 fn description(&self) -> Option<&str> {
1852 Some("Kill a terminal process. The terminal handle remains in the registry for history.")
1853 }
1854 fn input_schema(&self) -> serde_json::Value {
1855 serde_json::json!({
1856 "type": "object",
1857 "properties": {"handle": {"type": "string"}},
1858 "required": ["handle"]
1859 })
1860 }
1861 fn call<'a>(
1862 &'a self,
1863 args: crate::tool::ToolArgs,
1864 ctx: &'a crate::tool::ToolCtx,
1865 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1866 Box::pin(async move {
1867 let handle = extract_string(&args, "handle", 0)?;
1868 let registry = ctx.term_registry.clone().ok_or_else(|| {
1869 RuntimeError::ToolFailed("term.kill: registry not available".into())
1870 })?;
1871 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1872 let entry = registry.lookup(&handle, &session_id)?;
1873 entry.stop();
1874 Ok(Value::Struct(vec![
1875 ("ok".into(), Value::Bool(true)),
1876 ("state".into(), Value::Str("killed".into())),
1877 ]))
1878 })
1879 }
1880}
1881
1882pub struct TermList;
1883impl Tool for TermList {
1884 fn name(&self) -> &str {
1885 "term.list"
1886 }
1887 fn tier(&self) -> Tier {
1888 Tier::Four
1889 }
1890 fn description(&self) -> Option<&str> {
1891 Some("List all terminal handles in the current session.")
1892 }
1893 fn input_schema(&self) -> serde_json::Value {
1894 serde_json::json!({
1895 "type": "object",
1896 "properties": {"all": {"type": "boolean", "default": false}}
1897 })
1898 }
1899 fn call<'a>(
1900 &'a self,
1901 args: crate::tool::ToolArgs,
1902 ctx: &'a crate::tool::ToolCtx,
1903 ) -> crate::tool::BoxFut<'a, crate::tool::ToolResult> {
1904 Box::pin(async move {
1905 let all = args
1906 .named("all")
1907 .and_then(|v| {
1908 if let Value::Bool(b) = v {
1909 Some(*b)
1910 } else {
1911 None
1912 }
1913 })
1914 .unwrap_or(false);
1915 let registry = ctx.term_registry.clone().ok_or_else(|| {
1916 RuntimeError::ToolFailed("term.list: registry not available".into())
1917 })?;
1918 let session_id = ctx.session_id.clone().unwrap_or_else(|| "anon".into());
1919 let _ = all;
1920 let list = registry.list(&session_id);
1921 let entries: Vec<Value> = list
1922 .iter()
1923 .map(|(h, st)| {
1924 Value::Struct(vec![
1925 ("handle".into(), Value::Str(h.clone())),
1926 ("state".into(), state_to_value(st)),
1927 ])
1928 })
1929 .collect();
1930 Ok(Value::Struct(vec![(
1931 "terminals".into(),
1932 Value::List(entries),
1933 )]))
1934 })
1935 }
1936}
1937
1938#[cfg(test)]
1939mod tests {
1940 use super::*;
1941 use crate::tool::{ToolArgs, ToolCtx};
1942 use std::sync::atomic::{AtomicUsize, Ordering};
1943
1944 #[derive(Debug)]
1945 struct RecordingChild {
1946 kill_calls: Arc<AtomicUsize>,
1947 wait_calls: Arc<AtomicUsize>,
1948 }
1949
1950 #[derive(Debug)]
1951 struct RecordingKiller(Arc<AtomicUsize>);
1952
1953 impl portable_pty::ChildKiller for RecordingKiller {
1954 fn kill(&mut self) -> std::io::Result<()> {
1955 self.0.fetch_add(1, Ordering::SeqCst);
1956 Ok(())
1957 }
1958
1959 fn clone_killer(&self) -> Box<dyn portable_pty::ChildKiller + Send + Sync> {
1960 Box::new(Self(self.0.clone()))
1961 }
1962 }
1963
1964 impl portable_pty::ChildKiller for RecordingChild {
1965 fn kill(&mut self) -> std::io::Result<()> {
1966 self.kill_calls.fetch_add(1, Ordering::SeqCst);
1967 Ok(())
1968 }
1969
1970 fn clone_killer(&self) -> Box<dyn portable_pty::ChildKiller + Send + Sync> {
1971 Box::new(RecordingKiller(self.kill_calls.clone()))
1972 }
1973 }
1974
1975 impl portable_pty::Child for RecordingChild {
1976 fn try_wait(&mut self) -> std::io::Result<Option<portable_pty::ExitStatus>> {
1977 Ok(None)
1978 }
1979
1980 fn wait(&mut self) -> std::io::Result<portable_pty::ExitStatus> {
1981 self.wait_calls.fetch_add(1, Ordering::SeqCst);
1982 Ok(portable_pty::ExitStatus::with_exit_code(0))
1983 }
1984
1985 fn process_id(&self) -> Option<u32> {
1986 Some(42)
1987 }
1988 }
1989
1990 #[derive(Clone, Copy)]
1991 enum PtyIoFailure {
1992 Reader,
1993 Writer,
1994 Never,
1995 }
1996
1997 struct FailingMaster(PtyIoFailure);
1998
1999 impl portable_pty::MasterPty for FailingMaster {
2000 fn resize(&self, _size: portable_pty::PtySize) -> anyhow::Result<()> {
2001 Ok(())
2002 }
2003
2004 fn get_size(&self) -> anyhow::Result<portable_pty::PtySize> {
2005 Ok(portable_pty::PtySize::default())
2006 }
2007
2008 fn try_clone_reader(&self) -> anyhow::Result<Box<dyn std::io::Read + Send>> {
2009 if matches!(self.0, PtyIoFailure::Reader) {
2010 anyhow::bail!("reader sentinel")
2011 }
2012 Ok(Box::new(std::io::empty()))
2013 }
2014
2015 fn take_writer(&self) -> anyhow::Result<Box<dyn std::io::Write + Send>> {
2016 if matches!(self.0, PtyIoFailure::Writer) {
2017 anyhow::bail!("writer sentinel")
2018 }
2019 Ok(Box::new(std::io::sink()))
2020 }
2021
2022 #[cfg(unix)]
2023 fn process_group_leader(&self) -> Option<i32> {
2024 None
2025 }
2026
2027 #[cfg(unix)]
2028 fn as_raw_fd(&self) -> Option<std::os::fd::RawFd> {
2029 None
2030 }
2031
2032 #[cfg(unix)]
2033 fn tty_name(&self) -> Option<PathBuf> {
2034 None
2035 }
2036 }
2037
2038 fn recording_child() -> (RecordingChild, Arc<AtomicUsize>, Arc<AtomicUsize>) {
2039 let kill_calls = Arc::new(AtomicUsize::new(0));
2040 let wait_calls = Arc::new(AtomicUsize::new(0));
2041 (
2042 RecordingChild {
2043 kill_calls: kill_calls.clone(),
2044 wait_calls: wait_calls.clone(),
2045 },
2046 kill_calls,
2047 wait_calls,
2048 )
2049 }
2050
2051 struct ChannelReader {
2052 chunks: std::sync::mpsc::Receiver<Vec<u8>>,
2053 pending: std::io::Cursor<Vec<u8>>,
2054 }
2055
2056 impl std::io::Read for ChannelReader {
2057 fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
2058 if self.pending.position() < self.pending.get_ref().len() as u64 {
2059 return self.pending.read(buf);
2060 }
2061 match self.chunks.recv() {
2062 Ok(chunk) => {
2063 self.pending = std::io::Cursor::new(chunk);
2064 self.pending.read(buf)
2065 }
2066 Err(_) => Ok(0),
2067 }
2068 }
2069 }
2070
2071 fn recording_pty_result(
2072 profile: crate::sandbox::TempProfile,
2073 ) -> (PtySpawnResult, Arc<AtomicUsize>, Arc<AtomicUsize>, PathBuf) {
2074 let profile_path = profile.path().to_path_buf();
2075 let (child, kill_calls, wait_calls) = recording_child();
2076 (
2077 PtySpawnResult {
2078 child: Box::new(child),
2079 reader: Box::new(std::io::empty()),
2080 writer: Box::new(std::io::sink()),
2081 master: Box::new(FailingMaster(PtyIoFailure::Never)),
2082 profile: Some(profile),
2083 },
2084 kill_calls,
2085 wait_calls,
2086 profile_path,
2087 )
2088 }
2089
2090 #[tokio::test]
2091 async fn successful_pty_releases_profile_after_exit_and_preserves_history() {
2092 let root = tempfile::tempdir().unwrap();
2093 let profile = crate::sandbox::TempProfile::create("live profile").unwrap();
2094 let profile_path = profile.path().to_path_buf();
2095 let (child, _, _) = recording_child();
2096 let (chunks_tx, chunks_rx) = std::sync::mpsc::channel();
2097 let pty_result = PtySpawnResult {
2098 child: Box::new(child),
2099 reader: Box::new(ChannelReader {
2100 chunks: chunks_rx,
2101 pending: std::io::Cursor::new(Vec::new()),
2102 }),
2103 writer: Box::new(std::io::sink()),
2104 master: Box::new(FailingMaster(PtyIoFailure::Never)),
2105 profile: Some(profile),
2106 };
2107 let registry = Arc::new(TermRegistry::new());
2108 let (handle, entry) = registry
2109 .spawn_entry(
2110 24,
2111 80,
2112 "success".into(),
2113 root.path().to_path_buf(),
2114 pty_result,
2115 None,
2116 "success".into(),
2117 "printf success".into(),
2118 None,
2119 None,
2120 tokio_util::sync::CancellationToken::new(),
2121 None,
2122 None,
2123 )
2124 .unwrap();
2125
2126 assert!(profile_path.exists());
2127 chunks_tx.send(b"completed output".to_vec()).unwrap();
2128 drop(chunks_tx);
2129 let reader_task = entry
2130 .reader_task
2131 .lock()
2132 .expect("reader_task poisoned")
2133 .take()
2134 .unwrap();
2135 reader_task.await.unwrap();
2136
2137 assert!(!profile_path.exists());
2138 assert!(matches!(entry.current_state(), TermState::Exited { .. }));
2139 let historical = registry.lookup(&handle.to_string(), "success").unwrap();
2140 let screen = historical.snapshot();
2141 let first_row = screen.cells[..usize::from(screen.cols)]
2142 .iter()
2143 .map(|cell| cell.chars.as_str())
2144 .collect::<String>();
2145 assert!(first_row.contains("completed output"));
2146 }
2147
2148 #[tokio::test]
2149 async fn operator_stop_stays_killed_after_reader_exit() {
2150 let root = tempfile::tempdir().unwrap();
2151 let tasks = crate::task_registry::TaskRegistry::new();
2152 let registry = Arc::new(TermRegistry::new().with_task_registry(tasks.clone()));
2153 let (child, kill_calls, _) = recording_child();
2154 let (chunks_tx, chunks_rx) = std::sync::mpsc::channel();
2155 let pty_result = PtySpawnResult {
2156 child: Box::new(child),
2157 reader: Box::new(ChannelReader {
2158 chunks: chunks_rx,
2159 pending: std::io::Cursor::new(Vec::new()),
2160 }),
2161 writer: Box::new(std::io::sink()),
2162 master: Box::new(FailingMaster(PtyIoFailure::Never)),
2163 profile: None,
2164 };
2165 let (handle, entry) = registry
2166 .spawn_entry(
2167 24,
2168 80,
2169 "session".into(),
2170 root.path().to_path_buf(),
2171 pty_result,
2172 None,
2173 "terminal".into(),
2174 "sleep 10".into(),
2175 None,
2176 None,
2177 tokio_util::sync::CancellationToken::new(),
2178 None,
2179 None,
2180 )
2181 .unwrap();
2182
2183 assert!(matches!(
2184 tasks.kill_by_handle_from_operator(&handle.to_string(), "session"),
2185 crate::task_registry::KillOutcome::Killed { .. }
2186 ));
2187 assert!(matches!(entry.current_state(), TermState::Killed { .. }));
2188 assert_eq!(kill_calls.load(Ordering::SeqCst), 1);
2189
2190 drop(chunks_tx);
2191 let reader_task = entry
2192 .reader_task
2193 .lock()
2194 .expect("reader_task poisoned")
2195 .take()
2196 .unwrap();
2197 reader_task.await.unwrap();
2198
2199 assert_eq!(
2200 tasks.lookup_by_handle(&handle.to_string()).unwrap().status,
2201 crate::task_registry::TaskStatus::Killed
2202 );
2203 assert!(matches!(entry.current_state(), TermState::Killed { .. }));
2204 }
2205
2206 #[test]
2207 fn pty_reader_failure_terminates_child_and_drops_profile() {
2208 let profile = crate::sandbox::TempProfile::create("reader profile").unwrap();
2209 let profile_path = profile.path().to_path_buf();
2210 let (child, kill_calls, wait_calls) = recording_child();
2211 let error = crate::sandbox::complete_pty_spawn(
2212 Box::new(child),
2213 Box::new(FailingMaster(PtyIoFailure::Reader)),
2214 Some(profile),
2215 )
2216 .err()
2217 .expect("reader acquisition must fail");
2218 assert!(error.to_string().contains("reader sentinel"));
2219 assert_eq!(kill_calls.load(Ordering::SeqCst), 1);
2220 assert_eq!(wait_calls.load(Ordering::SeqCst), 1);
2221 assert!(!profile_path.exists());
2222 }
2223
2224 #[test]
2225 fn pty_writer_failure_terminates_child_and_drops_profile() {
2226 let profile = crate::sandbox::TempProfile::create("writer profile").unwrap();
2227 let profile_path = profile.path().to_path_buf();
2228 let (child, kill_calls, wait_calls) = recording_child();
2229 let error = crate::sandbox::complete_pty_spawn(
2230 Box::new(child),
2231 Box::new(FailingMaster(PtyIoFailure::Writer)),
2232 Some(profile),
2233 )
2234 .err()
2235 .expect("writer acquisition must fail");
2236 assert!(error.to_string().contains("writer sentinel"));
2237 assert_eq!(kill_calls.load(Ordering::SeqCst), 1);
2238 assert_eq!(wait_calls.load(Ordering::SeqCst), 1);
2239 assert!(!profile_path.exists());
2240 }
2241
2242 #[test]
2243 fn session_directory_failure_leaves_no_terminal_resources() {
2244 let root = tempfile::tempdir().unwrap();
2245 let blocker = root.path().join("not-a-directory");
2246 let session_dir = blocker.join("session");
2247 std::fs::write(&blocker, "block directory creation").unwrap();
2248 let profile = crate::sandbox::TempProfile::create("directory profile").unwrap();
2249 let (pty_result, kill_calls, wait_calls, profile_path) = recording_pty_result(profile);
2250 let tasks = crate::task_registry::TaskRegistry::new();
2251 let registry = Arc::new(TermRegistry::new().with_task_registry(tasks.clone()));
2252 let error = registry
2253 .spawn_entry(
2254 24,
2255 80,
2256 "dir-failure".into(),
2257 session_dir.clone(),
2258 pty_result,
2259 None,
2260 "terminal".into(),
2261 "sh".into(),
2262 None,
2263 None,
2264 tokio_util::sync::CancellationToken::new(),
2265 None,
2266 None,
2267 )
2268 .err()
2269 .expect("session directory creation must fail");
2270 assert!(error.to_string().contains("create session_dir"));
2271 assert_eq!(kill_calls.load(Ordering::SeqCst), 1);
2272 assert_eq!(wait_calls.load(Ordering::SeqCst), 1);
2273 assert!(!profile_path.exists());
2274 assert!(registry.list("dir-failure").is_empty());
2275 assert!(
2276 tasks
2277 .list(&crate::task_registry::TaskFilter::all())
2278 .is_empty()
2279 );
2280 assert!(!session_dir.exists());
2281 }
2282
2283 #[test]
2284 fn log_open_failure_after_directory_creation_leaves_no_terminal_resources() {
2285 let root = tempfile::tempdir().unwrap();
2286 let session_dir = root.path().join("session");
2287 let profile = crate::sandbox::TempProfile::create("log profile").unwrap();
2288 let (pty_result, kill_calls, wait_calls, profile_path) = recording_pty_result(profile);
2289 let tasks = crate::task_registry::TaskRegistry::new();
2290 let registry = Arc::new(TermRegistry::new().with_task_registry(tasks.clone()));
2291 let opener_calls = AtomicUsize::new(0);
2292 let error = registry
2293 .spawn_entry_with_log_opener(
2294 24,
2295 80,
2296 "log-failure".into(),
2297 session_dir.clone(),
2298 pty_result,
2299 None,
2300 "terminal".into(),
2301 "sh".into(),
2302 None,
2303 None,
2304 tokio_util::sync::CancellationToken::new(),
2305 None,
2306 None,
2307 |path| {
2308 opener_calls.fetch_add(1, Ordering::SeqCst);
2309 assert!(session_dir.is_dir());
2310 assert!(path.starts_with(&session_dir));
2311 Err(std::io::Error::other("log sentinel"))
2312 },
2313 )
2314 .err()
2315 .expect("log opening must fail");
2316 assert!(error.to_string().contains("log sentinel"));
2317 assert_eq!(opener_calls.load(Ordering::SeqCst), 1);
2318 assert_eq!(kill_calls.load(Ordering::SeqCst), 1);
2319 assert_eq!(wait_calls.load(Ordering::SeqCst), 1);
2320 assert!(!profile_path.exists());
2321 assert!(registry.list("log-failure").is_empty());
2322 assert!(
2323 tasks
2324 .list(&crate::task_registry::TaskFilter::all())
2325 .is_empty()
2326 );
2327 assert!(std::fs::read_dir(&session_dir).unwrap().next().is_none());
2328 }
2329
2330 #[test]
2331 fn spawn_provenance_uses_cwd_not_cmd() {
2332 let dir = tempfile::tempdir().unwrap();
2333 let ctx = ToolCtx::default();
2334 let args = ToolArgs {
2335 named: vec![
2336 ("cmd".into(), Value::Str("/bin/echo hi".into())),
2337 ("cwd".into(), Value::Str(dir.path().display().to_string())),
2338 ],
2339 ..ToolArgs::default()
2340 };
2341 let provenance = TermSpawn.invocation_provenance(&args, &ctx).unwrap();
2342 let cwd = provenance.cwd.expect("cwd recorded");
2343 assert_eq!(
2344 std::fs::canonicalize(&cwd).unwrap(),
2345 std::fs::canonicalize(dir.path()).unwrap()
2346 );
2347 assert_eq!(provenance.path, None);
2348 assert!(
2349 provenance
2350 .risks
2351 .contains(&crate::trust::RiskKind::ProcessSpawn)
2352 );
2353 }
2354
2355 struct StrictRecordingSandbox {
2356 pty_calls: AtomicUsize,
2357 }
2358
2359 struct DenyingSandbox {
2360 strict_calls: AtomicUsize,
2361 }
2362
2363 impl crate::sandbox::Sandbox for DenyingSandbox {
2364 fn spawn<'a>(
2365 &'a self,
2366 _cmd: &'a [&'a str],
2367 _env: &'a [(String, String)],
2368 _cwd: &'a Path,
2369 _authorization: &'a crate::permission::InvocationAuthorization,
2370 ) -> crate::tool::BoxFut<'a, Result<std::process::Output, RuntimeError>> {
2371 Box::pin(async { Err(RuntimeError::ToolFailed("Operation not permitted".into())) })
2372 }
2373
2374 fn prepare_background(
2375 &self,
2376 _cmd: &[&str],
2377 _env: &[(String, String)],
2378 _cwd: &Path,
2379 _authorization: &crate::permission::InvocationAuthorization,
2380 ) -> Result<Box<dyn crate::sandbox::BackgroundLauncher>, crate::sandbox::SandboxLaunchError>
2381 {
2382 Err(crate::sandbox::SandboxLaunchError::Runtime(
2383 RuntimeError::ToolFailed("background unsupported".into()),
2384 ))
2385 }
2386
2387 fn spawn_pty<'a>(
2388 &'a self,
2389 _cmd: &'a [&'a str],
2390 _env: &'a [(String, String)],
2391 _cwd: &'a Path,
2392 _pty_size: portable_pty::PtySize,
2393 authorization: &'a crate::permission::InvocationAuthorization,
2394 ) -> crate::tool::BoxFut<'a, Result<PtySpawnResult, crate::sandbox::SandboxLaunchError>>
2395 {
2396 self.strict_calls.fetch_add(1, Ordering::SeqCst);
2397 Box::pin(async move {
2398 Err(crate::sandbox::SandboxLaunchError::Denied(Box::new(
2399 crate::sandbox::SandboxDenial {
2400 operation: crate::sandbox::SandboxOperation::PtySpawn,
2401 reason: "Operation not permitted".into(),
2402 provenance: authorization.provenance().clone(),
2403 },
2404 )))
2405 })
2406 }
2407
2408 fn is_available(&self) -> bool {
2409 true
2410 }
2411
2412 fn kind(&self) -> &'static str {
2413 "test-denial"
2414 }
2415 }
2416
2417 impl crate::sandbox::Sandbox for StrictRecordingSandbox {
2418 fn spawn<'a>(
2419 &'a self,
2420 _cmd: &'a [&'a str],
2421 _env: &'a [(String, String)],
2422 _cwd: &'a Path,
2423 _authorization: &'a crate::permission::InvocationAuthorization,
2424 ) -> crate::tool::BoxFut<'a, Result<std::process::Output, RuntimeError>> {
2425 Box::pin(async { Err(RuntimeError::ToolFailed("strict sentinel".into())) })
2426 }
2427
2428 fn prepare_background(
2429 &self,
2430 _cmd: &[&str],
2431 _env: &[(String, String)],
2432 _cwd: &Path,
2433 _authorization: &crate::permission::InvocationAuthorization,
2434 ) -> Result<Box<dyn crate::sandbox::BackgroundLauncher>, crate::sandbox::SandboxLaunchError>
2435 {
2436 Err(crate::sandbox::SandboxLaunchError::Runtime(
2437 RuntimeError::ToolFailed("background unsupported".into()),
2438 ))
2439 }
2440
2441 fn spawn_pty<'a>(
2442 &'a self,
2443 _cmd: &'a [&'a str],
2444 _env: &'a [(String, String)],
2445 _cwd: &'a Path,
2446 _pty_size: portable_pty::PtySize,
2447 _authorization: &'a crate::permission::InvocationAuthorization,
2448 ) -> crate::tool::BoxFut<'a, Result<PtySpawnResult, crate::sandbox::SandboxLaunchError>>
2449 {
2450 self.pty_calls.fetch_add(1, Ordering::SeqCst);
2451 Box::pin(async {
2452 Err(crate::sandbox::SandboxLaunchError::Runtime(
2453 RuntimeError::ToolFailed("strict sentinel".into()),
2454 ))
2455 })
2456 }
2457
2458 fn is_available(&self) -> bool {
2459 true
2460 }
2461 fn kind(&self) -> &'static str {
2462 "test"
2463 }
2464 }
2465
2466 fn managed_term_ctx(
2467 workspace: &Path,
2468 registry: Arc<TermRegistry>,
2469 session_dir: &Path,
2470 ) -> ToolCtx {
2471 let mut ctx = ToolCtx::new()
2472 .with_trust(crate::trust::TrustConfig {
2473 mode: crate::trust::TrustMode::Reckless,
2474 ..crate::trust::TrustConfig::default()
2475 })
2476 .with_term_registry(registry)
2477 .with_session_dir(session_dir.to_path_buf())
2478 .with_fs_access(crate::fs_access::FsAccessPolicy::workspace_write(
2479 workspace.to_path_buf(),
2480 ))
2481 .with_workspace(crate::git_workspace::WorkspaceBinding {
2482 workspace_id: "test".into(),
2483 repository_root: workspace.to_path_buf(),
2484 path: workspace.to_path_buf(),
2485 branch: None,
2486 });
2487 ctx.session_id = Some("r4".into());
2488 ctx.for_tool_invocation(crate::tool::Tier::Four)
2489 .authorized_for(crate::permission::InvocationAuthorization::new(
2490 crate::permission::PermissionRequestId::now(),
2491 "test-call",
2492 "term.spawn",
2493 crate::permission::ResourceProvenance::none(),
2494 crate::permission::ExecutionBoundary::Direct,
2495 ))
2496 }
2497
2498 fn term_args(cwd: &Path) -> ToolArgs {
2499 ToolArgs {
2500 positional: vec![],
2501 named: vec![
2502 ("cmd".into(), Value::Str("exit 0".into())),
2503 ("cwd".into(), Value::Str(cwd.to_string_lossy().into())),
2504 ],
2505 }
2506 }
2507
2508 fn brokered_term_ctx(
2509 workspace: &Path,
2510 registry: Arc<TermRegistry>,
2511 session_dir: &Path,
2512 trust: crate::trust::TrustConfig,
2513 ) -> ToolCtx {
2514 let flows = Arc::new(crate::tools::agent_ctrl::FlowRegistry::default());
2515 let identity = flows
2516 .register_root(
2517 "r4".into(),
2518 crate::event::FlowRunId::now(),
2519 crate::flow_authority::EffectiveAuthority::root(&trust, true, None),
2520 )
2521 .unwrap();
2522 let broker = crate::permission::PermissionBroker::shared(Arc::clone(&flows));
2523 let mut ctx = managed_term_ctx(workspace, registry, session_dir)
2524 .with_flow_registry(flows)
2525 .with_permission_broker(broker)
2526 .with_trust(trust)
2527 .with_approval(Arc::new(crate::session::ApprovalRegistry::new()))
2528 .with_anchors(None, Some(identity.run_id.clone()), None);
2529 ctx.flow_identity = Some(identity);
2530 ctx.for_tool_invocation(crate::tool::Tier::Four)
2531 }
2532
2533 #[tokio::test]
2534 async fn sandbox_strict_path_receives_external_cwd_before_fs_policy() {
2535 let workspace = tempfile::tempdir().unwrap();
2536 let session_dir = tempfile::tempdir().unwrap();
2537 let registry = Arc::new(TermRegistry::new());
2538 let sandbox = Arc::new(StrictRecordingSandbox {
2539 pty_calls: AtomicUsize::new(0),
2540 });
2541 let args = term_args(Path::new(env!("CARGO_MANIFEST_DIR")));
2542 let mut trust = eager_spawn_policy();
2543 trust.risks.eager.outside_workspace = Some(crate::trust::PolicyAction::Auto);
2544 let ctx = brokered_term_ctx(workspace.path(), registry, session_dir.path(), trust)
2545 .with_sandbox(sandbox.clone());
2546 let ctx = authorize_term_spawn(ctx, &args).await;
2547 let error = spawn_impl(args, &ctx).await.unwrap_err();
2548 assert!(error.to_string().contains("strict sentinel"));
2549 assert_eq!(sandbox.pty_calls.load(Ordering::SeqCst), 1);
2550 }
2551
2552 #[tokio::test]
2553 async fn direct_authorization_skips_available_sandbox() {
2554 let workspace = tempfile::tempdir().unwrap();
2555 let session_dir = tempfile::tempdir().unwrap();
2556 let registry = Arc::new(TermRegistry::new());
2557 let sandbox = Arc::new(StrictRecordingSandbox {
2558 pty_calls: AtomicUsize::new(0),
2559 });
2560 let ctx = managed_term_ctx(workspace.path(), Arc::clone(®istry), session_dir.path())
2561 .with_sandbox(sandbox.clone());
2562
2563 spawn_impl(term_args(workspace.path()), &ctx).await.unwrap();
2564
2565 assert_eq!(sandbox.pty_calls.load(Ordering::SeqCst), 0);
2566 registry.kill_all();
2567 }
2568
2569 async fn authorize_term_spawn(ctx: ToolCtx, args: &ToolArgs) -> ToolCtx {
2570 match crate::approval::request_approval(
2571 &ctx,
2572 "term.spawn",
2573 "term.spawn",
2574 args,
2575 crate::tool::ApprovalLevel::Dangerous,
2576 Some(&TermSpawn),
2577 )
2578 .await
2579 {
2580 crate::approval::ApprovalOutcome::Approve { authorization } => {
2581 ctx.authorized_for(*authorization)
2582 }
2583 crate::approval::ApprovalOutcome::Deny { reason } => {
2584 panic!("strict term spawn authorization denied: {reason}")
2585 }
2586 }
2587 }
2588
2589 fn eager_spawn_policy() -> crate::trust::TrustConfig {
2590 crate::trust::TrustConfig {
2591 mode: crate::trust::TrustMode::Eager,
2592 escalation: crate::trust::EscalationPolicy::Deny,
2593 ..crate::trust::TrustConfig::default()
2594 }
2595 }
2596
2597 #[tokio::test]
2598 async fn sandbox_denial_never_relaunches_or_registers_resources() {
2599 let workspace = tempfile::tempdir().unwrap();
2600 let session_dir = tempfile::tempdir().unwrap();
2601 let tasks = crate::task_registry::TaskRegistry::new();
2602 let registry = Arc::new(TermRegistry::new().with_task_registry(tasks.clone()));
2603 let sandbox = Arc::new(DenyingSandbox {
2604 strict_calls: AtomicUsize::new(0),
2605 });
2606 let ctx = brokered_term_ctx(
2607 workspace.path(),
2608 registry.clone(),
2609 session_dir.path(),
2610 eager_spawn_policy(),
2611 )
2612 .with_sandbox(sandbox.clone())
2613 .with_task_registry(tasks.clone());
2614 let args = term_args(workspace.path());
2615 let ctx = authorize_term_spawn(ctx, &args).await;
2616
2617 let error = spawn_impl(args, &ctx).await.unwrap_err();
2618
2619 assert!(error.to_string().contains("Operation not permitted"));
2620 assert_eq!(sandbox.strict_calls.load(Ordering::SeqCst), 1);
2621 assert!(registry.list("r4").is_empty());
2622 assert!(
2623 tasks
2624 .list(&crate::task_registry::TaskFilter::all())
2625 .is_empty()
2626 );
2627 }
2628
2629 #[tokio::test]
2630 async fn direct_spawn_allows_external_temp_cwd() {
2631 let workspace = tempfile::tempdir().unwrap();
2632 let external = tempfile::tempdir().unwrap();
2633 let session_dir = tempfile::tempdir().unwrap();
2634 let registry = Arc::new(TermRegistry::new());
2635 let ctx = managed_term_ctx(workspace.path(), registry.clone(), session_dir.path());
2636 spawn_impl(term_args(external.path()), &ctx).await.unwrap();
2637 assert_eq!(registry.list("r4").len(), 1);
2638 registry.kill_all();
2639 }
2640
2641 #[tokio::test]
2642 async fn direct_spawn_allows_external_cwd_under_full_access() {
2643 let workspace = tempfile::tempdir().unwrap();
2644 let session_dir = tempfile::tempdir().unwrap();
2645 let registry = Arc::new(TermRegistry::new());
2646 let ctx = managed_term_ctx(workspace.path(), registry.clone(), session_dir.path())
2647 .with_fs_access(crate::fs_access::FsAccessPolicy::danger_full_access());
2648 spawn_impl(term_args(Path::new(env!("CARGO_MANIFEST_DIR"))), &ctx)
2649 .await
2650 .unwrap();
2651 assert_eq!(registry.list("r4").len(), 1);
2652 registry.kill_all();
2653 }
2654
2655 #[test]
2656 fn handle_parse_roundtrip() {
2657 let h = TermHandle {
2658 session_id: "abc".into(),
2659 local_id: 7,
2660 };
2661 assert_eq!(h.to_string(), "term_abc_7");
2662 let back = TermHandle::parse("term_abc_7").unwrap();
2663 assert_eq!(back, h);
2664 }
2665
2666 #[test]
2667 fn handle_parse_rejects_bad_format() {
2668 assert!(TermHandle::parse("not_term").is_none());
2669 assert!(TermHandle::parse("term_nosuffix").is_none());
2670 assert!(TermHandle::parse("term_x_notnum").is_none());
2671 }
2672
2673 #[test]
2674 fn snapshot_screen_captures_text() {
2675 let mut parser = vt100::Parser::new(3, 5, 0);
2676 parser.process(b"hello");
2677 let screen = snapshot_screen(&parser);
2678 assert_eq!(screen.rows, 3);
2679 assert_eq!(screen.cols, 5);
2680 assert_eq!(screen.cells.len(), 15);
2681 assert_eq!(screen.cells[0].chars, "h");
2682 assert_eq!(screen.cells[4].chars, "o");
2683 }
2684
2685 #[tokio::test]
2686 async fn capture_spills_text_to_output_store_with_byte_continuation() {
2687 const ROWS: u16 = 4096;
2688 const COLS: u16 = 256;
2689 const CONTENT_COLS: usize = 255;
2690 const EXPECTED_TEXT_BYTES: usize = ROWS as usize * CONTENT_COLS + (ROWS as usize - 1);
2691
2692 let registry = Arc::new(TermRegistry::new());
2693 let handle = registry.next_handle("session_a");
2694 let row = "a".repeat(CONTENT_COLS);
2695 let expected_text = vec![row.as_str(); ROWS as usize].join("\n");
2696 assert_eq!(expected_text.len(), EXPECTED_TEXT_BYTES);
2697 assert_eq!(EXPECTED_TEXT_BYTES, 1_048_575);
2698 let terminal_input = expected_text.replace('\n', "\r\n");
2699 let mut parser = vt100::Parser::new(ROWS, COLS, 0);
2700 parser.process(terminal_input.as_bytes());
2701 let entry = Arc::new(TermEntry {
2702 handle: handle.clone(),
2703 session_id: "session_a".into(),
2704 pty_size: portable_pty::PtySize {
2705 rows: ROWS,
2706 cols: COLS,
2707 pixel_width: 0,
2708 pixel_height: 0,
2709 },
2710 parser: Arc::new(Mutex::new(parser)),
2711 writer: Mutex::new(Box::new(std::io::sink())),
2712 state: Arc::new(Mutex::new(TermState::Running {
2713 pid: 0,
2714 started_at: 0,
2715 })),
2716 stream_tx: broadcast::channel(STREAM_CHANNEL_CAPACITY).0,
2717 log_path: std::env::temp_dir().join("term_capture_budget.log"),
2718 reader_task: Mutex::new(None),
2719 child: Mutex::new(None),
2720 master: Mutex::new(None),
2721 profile: Arc::new(Mutex::new(None)),
2722 started_at: Instant::now(),
2723 task_id: Mutex::new(None),
2724 });
2725 registry.insert(entry);
2726 let output_dir = tempfile::tempdir().unwrap();
2727 let mut ctx = crate::tool::ToolCtx::new()
2728 .with_term_registry(registry)
2729 .with_session_dir(output_dir.path().to_path_buf());
2730 ctx.session_id = Some("session_a".into());
2731 ctx.tool_output_budget = crate::tools::tool_output::ToolOutputBudget {
2732 max_lines: 32,
2733 max_bytes: 64 * 1024,
2734 max_line_bytes: 64 * 1024,
2735 };
2736
2737 let first = TermCapture
2738 .call(
2739 crate::tool::ToolArgs {
2740 positional: Vec::new(),
2741 named: vec![("handle".into(), Value::Str(handle.to_string()))],
2742 },
2743 &ctx,
2744 )
2745 .await
2746 .unwrap();
2747 let Value::Struct(fields) = first else {
2748 panic!("expected capture fields");
2749 };
2750 assert!(matches!(
2751 fields.iter().find(|(name, _)| name == "rows"),
2752 Some((_, Value::Int(rows))) if *rows == i64::from(ROWS)
2753 ));
2754 assert!(matches!(
2755 fields.iter().find(|(name, _)| name == "total_lines"),
2756 Some((_, Value::Int(lines))) if *lines == i64::from(ROWS)
2757 ));
2758 assert!(matches!(
2759 fields.iter().find(|(name, _)| name == "total_bytes"),
2760 Some((_, Value::Int(bytes))) if *bytes == EXPECTED_TEXT_BYTES as i64
2761 ));
2762 assert!(!fields.iter().any(|(name, _)| name == "continuation"));
2763 assert!(!fields.iter().any(|(name, _)| name == "TerminalArea"));
2764 assert!(!fields.iter().any(|(name, _)| name == "next_row"));
2765 assert!(!fields.iter().any(|(name, _)| name == "next_col"));
2766 assert!(!fields.iter().any(|(name, _)| name == "text"));
2767 let content = fields.iter().find_map(|(name, value)| {
2768 (name == "content").then(|| match value {
2769 Value::Str(text) => text.as_str(),
2770 _ => panic!("expected content string"),
2771 })
2772 });
2773 let Value::Str(output_id) = fields
2774 .iter()
2775 .find(|(name, _)| name == "output_id")
2776 .map(|(_, value)| value)
2777 .unwrap()
2778 else {
2779 panic!("expected output_id");
2780 };
2781 let Value::Struct(next) = fields
2782 .iter()
2783 .find(|(name, _)| name == "next")
2784 .map(|(_, value)| value)
2785 .unwrap()
2786 else {
2787 panic!("expected byte continuation");
2788 };
2789 let offset = next
2790 .iter()
2791 .find_map(|(name, value)| (name == "offset").then_some(value))
2792 .and_then(|value| match value {
2793 Value::Int(offset) => Some(*offset as usize),
2794 _ => None,
2795 })
2796 .unwrap();
2797 assert!(matches!(
2798 next.iter().find(|(name, _)| name == "mode"),
2799 Some((_, Value::Str(mode))) if mode == "bytes"
2800 ));
2801 let store = ctx.output_store.as_deref().unwrap();
2802 let mut reconstructed = content.unwrap().to_string();
2803 let mut page_offset = offset;
2804 loop {
2805 let page = store
2806 .read_bytes(output_id, page_offset, usize::MAX, ctx.tool_output_budget)
2807 .unwrap();
2808 assert_eq!(page.offset, page_offset);
2809 reconstructed.push_str(&page.content);
2810 if !page.has_more {
2811 break;
2812 }
2813 assert!(page.next_offset > page_offset);
2814 page_offset = page.next_offset;
2815 }
2816 assert_eq!(reconstructed.len(), EXPECTED_TEXT_BYTES);
2817 assert_eq!(reconstructed, expected_text);
2818
2819 let untruncated = TermCapture
2820 .call(
2821 crate::tool::ToolArgs {
2822 positional: Vec::new(),
2823 named: vec![
2824 ("handle".into(), Value::Str(handle.to_string())),
2825 ("end_row".into(), Value::Int(1)),
2826 ],
2827 },
2828 &ctx,
2829 )
2830 .await
2831 .unwrap();
2832 let Value::Struct(fields) = untruncated else {
2833 panic!("expected capture fields");
2834 };
2835 assert!(matches!(
2836 fields.iter().find(|(name, _)| name == "text"),
2837 Some((_, Value::Str(text))) if text == &row
2838 ));
2839 assert!(!fields.iter().any(|(name, _)| name == "continuation"));
2840 assert!(!fields.iter().any(|(name, _)| name == "output_id"));
2841 assert!(!fields.iter().any(|(name, _)| name == "next"));
2842
2843 let screen_capture = TermCapture
2844 .call(
2845 crate::tool::ToolArgs {
2846 positional: Vec::new(),
2847 named: vec![
2848 ("handle".into(), Value::Str(handle.to_string())),
2849 ("format".into(), Value::Str("screen".into())),
2850 ],
2851 },
2852 &ctx,
2853 )
2854 .await
2855 .unwrap();
2856 let Value::Struct(fields) = screen_capture else {
2857 panic!("expected capture fields");
2858 };
2859 let Value::Struct(continuation) = fields
2860 .iter()
2861 .find(|(name, _)| name == "continuation")
2862 .map(|(_, value)| value)
2863 .unwrap()
2864 else {
2865 panic!("expected screen continuation");
2866 };
2867 assert!(matches!(
2868 continuation.iter().find(|(name, _)| name == "type"),
2869 Some((_, Value::Str(kind))) if kind == "TerminalArea"
2870 ));
2871 assert!(continuation.iter().any(|(name, _)| name == "next_row"));
2872 assert!(fields.iter().any(|(name, _)| name == "screen"));
2873 }
2874
2875 #[test]
2876 fn registry_lookup_rejects_cross_session() {
2877 let registry = Arc::new(TermRegistry::new());
2878 let h = registry.next_handle("session_a");
2879 let entry = Arc::new(TermEntry {
2880 handle: h.clone(),
2881 session_id: "session_a".into(),
2882 pty_size: portable_pty::PtySize {
2883 rows: 24,
2884 cols: 80,
2885 pixel_width: 0,
2886 pixel_height: 0,
2887 },
2888 parser: Arc::new(Mutex::new(vt100::Parser::new(24, 80, 0))),
2889 writer: Mutex::new(Box::new(std::io::sink())),
2890 state: Arc::new(Mutex::new(TermState::Running {
2891 pid: 0,
2892 started_at: 0,
2893 })),
2894 stream_tx: broadcast::channel(STREAM_CHANNEL_CAPACITY).0,
2895 log_path: std::env::temp_dir().join("term_test_dummy.log"),
2896 reader_task: Mutex::new(None),
2897 child: Mutex::new(None),
2898 master: Mutex::new(None),
2899 profile: Arc::new(Mutex::new(None)),
2900 started_at: Instant::now(),
2901 task_id: Mutex::new(None),
2902 });
2903 registry.insert(entry);
2904 assert!(registry.lookup(&h.to_string(), "session_a").is_ok());
2905 assert!(registry.lookup(&h.to_string(), "session_b").is_err());
2906 }
2907
2908 fn make_screen(rows: u16, cols: u16, text: &str) -> TerminalScreen {
2909 let mut parser = vt100::Parser::new(rows, cols, 0);
2910 parser.process(text.as_bytes());
2911 snapshot_screen(&parser)
2912 }
2913
2914 #[test]
2915 fn cell_text_returns_chars_for_filled_cell() {
2916 let screen = make_screen(1, 5, "hello");
2917 assert_eq!(cell_text(&screen, 0, 0), "h");
2918 assert_eq!(cell_text(&screen, 0, 4), "o");
2919 }
2920
2921 #[test]
2922 fn cell_text_returns_space_for_empty_cell() {
2923 let screen = make_screen(1, 5, "hi");
2924 assert_eq!(cell_text(&screen, 0, 2), " ");
2925 }
2926
2927 #[test]
2928 fn find_pattern_returns_position() {
2929 let screen = make_screen(2, 10, "hello\r\nworld");
2930 let pos = find_pattern_on_screen(&screen, "world");
2931 assert_eq!(pos, Some((1, 0)));
2932 }
2933
2934 #[test]
2935 fn find_pattern_returns_none_when_absent() {
2936 let screen = make_screen(1, 5, "hello");
2937 assert!(find_pattern_on_screen(&screen, "xyz").is_none());
2938 }
2939
2940 #[test]
2941 fn color_name_maps_to_ansi_idx() {
2942 assert_eq!(color_name_to_terminal("red"), Some(TerminalColor::Idx(1)));
2943 assert_eq!(
2944 color_name_to_terminal("default"),
2945 Some(TerminalColor::Default)
2946 );
2947 assert_eq!(color_name_to_terminal("nonexistent"), None);
2948 }
2949
2950 #[test]
2951 fn color_matches_bright_variant_to_base_name() {
2952 let desired = TerminalColor::Idx(1);
2953 let actual_bright = TerminalColor::Idx(9);
2954 assert!(color_matches(&desired, &actual_bright));
2955 let actual_normal = TerminalColor::Idx(1);
2956 assert!(color_matches(&desired, &actual_normal));
2957 }
2958
2959 #[test]
2960 fn color_matches_rgb_exact() {
2961 let desired = TerminalColor::Rgb(255, 128, 0);
2962 let actual = TerminalColor::Rgb(255, 128, 0);
2963 assert!(color_matches(&desired, &actual));
2964 let wrong = TerminalColor::Rgb(255, 128, 1);
2965 assert!(!color_matches(&desired, &wrong));
2966 }
2967
2968 #[test]
2969 fn parse_color_accepts_name_and_rgb() {
2970 assert_eq!(
2971 parse_color(&Value::Str("blue".into())),
2972 Some(TerminalColor::Idx(4))
2973 );
2974 let rgb = Value::Struct(vec![
2975 ("r".into(), Value::Int(10)),
2976 ("g".into(), Value::Int(20)),
2977 ("b".into(), Value::Int(30)),
2978 ]);
2979 assert_eq!(parse_color(&rgb), Some(TerminalColor::Rgb(10, 20, 30)));
2980 }
2981}