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