返回 CodeWhale
terminal_session.rs
根目录 / crates / tui / src / tools / terminal_session.rs
1 //! Stateful, PTY-backed terminal sessions.
2 //!
3 //! Live PTY processes remain deliberately process-local, while a non-secret
4 //! durable summary records identity, last-known cwd, lifecycle state, and
5 //! replacement history. A later process reports the shell as stale/lost and
6 //! starts a new identity; it never claims to reattach from a reused PID.
7
8 #[cfg(unix)]
9 use std::collections::{HashMap, VecDeque};
10 #[cfg(unix)]
11 use std::io::Write;
12 #[cfg(unix)]
13 use std::path::{Path, PathBuf};
14 #[cfg(unix)]
15 use std::sync::{Arc, Mutex, OnceLock};
16 #[cfg(unix)]
17 use std::time::{Duration, Instant};
18
19 use async_trait::async_trait;
20 #[cfg(unix)]
21 use serde::{Deserialize, Serialize};
22 use serde_json::json;
23 #[cfg(unix)]
24 use sha2::{Digest, Sha256};
25 #[cfg(unix)]
26 use uuid::Uuid;
27
28 use super::spec::{
29 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec,
30 };
31 #[cfg(unix)]
32 use super::spec::{optional_u64, required_str};
33
34 #[cfg(unix)]
35 const BUFFER_LIMIT: usize = 512 * 1024;
36 #[cfg(unix)]
37 const OUTPUT_LIMIT: usize = 12 * 1024;
38 #[cfg(unix)]
39 const DEFAULT_TIMEOUT_SECS: u64 = 120;
40 #[cfg(unix)]
41 const MAX_TIMEOUT_SECS: u64 = 600;
42 #[cfg(unix)]
43 const CANCEL_CONFIRM_TIMEOUT: Duration = Duration::from_secs(2);
44 #[cfg(unix)]
45 const CANCEL_SENTINEL_RETRY_INTERVAL: Duration = Duration::from_millis(50);
46
47 #[cfg(unix)]
48 struct TerminalSession {
49 writer: Arc<Mutex<Box<dyn Write + Send>>>,
50 child: Box<dyn portable_pty::Child + Send>,
51 output: Arc<Mutex<OutputBuffer>>,
52 read_cursor: u64,
53 command: Option<CommandState>,
54 durable: DurableTerminalRecord,
55 durable_path: PathBuf,
56 /// True when the live PTY was started through a real sandbox backend.
57 /// A later narrowed posture must not reuse an unsandboxed shell.
58 sandbox_confined: bool,
59 }
60
61 #[cfg(unix)]
62 #[derive(Clone, Debug, Serialize, Deserialize)]
63 struct DurableTerminalRecord {
64 schema_version: u32,
65 session_id: String,
66 runtime_nonce: String,
67 process_id: u32,
68 workspace: PathBuf,
69 name: String,
70 shell: String,
71 last_known_cwd: PathBuf,
72 environment_summary: EnvironmentSummary,
73 state: DurableTerminalState,
74 updated_at: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 previous: Option<Box<DurableTerminalRecord>>,
77 }
78
79 #[cfg(unix)]
80 #[derive(Clone, Debug, Serialize, Deserialize)]
81 struct EnvironmentSummary {
82 term: Option<String>,
83 virtual_env_active: bool,
84 conda_env_active: bool,
85 nix_shell_active: bool,
86 note: String,
87 }
88
89 #[cfg(unix)]
90 #[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
91 #[serde(rename_all = "snake_case")]
92 enum DurableTerminalState {
93 Running,
94 Idle,
95 Canceled,
96 Failed,
97 StaleLost,
98 Reset,
99 }
100
101 #[cfg(unix)]
102 struct CommandState {
103 marker: String,
104 }
105
106 #[cfg(unix)]
107 #[derive(Default)]
108 struct OutputBuffer {
109 bytes: VecDeque<u8>,
110 total: u64,
111 }
112
113 #[cfg(unix)]
114 impl OutputBuffer {
115 fn append(&mut self, data: &[u8]) {
116 self.total = self.total.saturating_add(data.len() as u64);
117 self.bytes.extend(data);
118 while self.bytes.len() > BUFFER_LIMIT {
119 let _ = self.bytes.pop_front();
120 }
121 }
122
123 fn text(&self) -> String {
124 String::from_utf8_lossy(&self.bytes.iter().copied().collect::<Vec<_>>()).into_owned()
125 }
126 }
127
128 #[cfg(unix)]
129 type SharedSession = Arc<Mutex<TerminalSession>>;
130
131 #[cfg(unix)]
132 #[derive(Clone, Debug, Hash, PartialEq, Eq)]
133 struct SessionKey {
134 workspace: PathBuf,
135 name: String,
136 }
137
138 #[cfg(unix)]
139 static SESSIONS: OnceLock<Mutex<HashMap<SessionKey, SharedSession>>> = OnceLock::new();
140
141 #[cfg(unix)]
142 static RUNTIME_NONCE: OnceLock<String> = OnceLock::new();
143
144 #[cfg(unix)]
145 fn runtime_nonce() -> &'static str {
146 RUNTIME_NONCE.get_or_init(|| Uuid::new_v4().to_string())
147 }
148
149 #[cfg(unix)]
150 fn sessions() -> &'static Mutex<HashMap<SessionKey, SharedSession>> {
151 SESSIONS.get_or_init(|| Mutex::new(HashMap::new()))
152 }
153
154 #[cfg(unix)]
155 fn session_key(name: &str, workspace: &Path) -> SessionKey {
156 SessionKey {
157 workspace: workspace
158 .canonicalize()
159 .unwrap_or_else(|_| workspace.to_path_buf()),
160 name: name.to_string(),
161 }
162 }
163
164 #[cfg(unix)]
165 fn durable_path(name: &str, workspace: &Path) -> Result<PathBuf, String> {
166 let workspace = workspace
167 .canonicalize()
168 .unwrap_or_else(|_| workspace.to_path_buf());
169 let mut hasher = Sha256::new();
170 hasher.update(workspace.to_string_lossy().as_bytes());
171 hasher.update(b"\0");
172 hasher.update(name.as_bytes());
173 let digest = hasher
174 .finalize()
175 .iter()
176 .map(|byte| format!("{byte:02x}"))
177 .collect::<String>();
178 #[cfg(test)]
179 let state_dir = workspace.join(".codewhale-test-terminal-sessions");
180 #[cfg(not(test))]
181 let state_dir = codewhale_config::ensure_state_dir("terminal-sessions")
182 .map_err(|error| format!("failed to resolve terminal session state directory: {error}"))?;
183 std::fs::create_dir_all(&state_dir)
184 .map_err(|error| format!("failed to create terminal session state directory: {error}"))?;
185 Ok(state_dir.join(format!("{}.json", &digest[..32])))
186 }
187
188 #[cfg(unix)]
189 fn load_durable(path: &Path) -> Option<DurableTerminalRecord> {
190 let bytes = std::fs::read(path).ok()?;
191 serde_json::from_slice(&bytes).ok()
192 }
193
194 #[cfg(unix)]
195 fn persist_durable(path: &Path, record: &DurableTerminalRecord) -> Result<(), String> {
196 let payload = serde_json::to_vec_pretty(record)
197 .map_err(|error| format!("failed to encode terminal session state: {error}"))?;
198 crate::utils::write_atomic(path, &payload)
199 .map_err(|error| format!("failed to persist terminal session state: {error}"))
200 }
201
202 #[cfg(unix)]
203 fn pty_lacks_required_sandbox(
204 policy: &crate::sandbox::SandboxPolicy,
205 applied: crate::sandbox::SandboxType,
206 ) -> bool {
207 policy.should_sandbox() && matches!(applied, crate::sandbox::SandboxType::None)
208 }
209
210 #[cfg(unix)]
211 fn pty_sandbox_policy(context: &ToolContext) -> crate::sandbox::SandboxPolicy {
212 context.elevated_sandbox_policy.clone().unwrap_or_default()
213 }
214
215 #[cfg(unix)]
216 fn prepare_pty_shell(
217 shell: &str,
218 workspace: &std::path::Path,
219 policy: crate::sandbox::SandboxPolicy,
220 ) -> Result<(crate::sandbox::ExecEnv, bool), String> {
221 let spec = crate::sandbox::CommandSpec {
222 program: shell.to_string(),
223 args: vec!["-i".to_string()],
224 cwd: workspace.to_path_buf(),
225 env: std::collections::HashMap::new(),
226 timeout: Duration::from_secs(24 * 60 * 60),
227 sandbox_policy: policy.clone(),
228 justification: Some("persistent PTY shell".to_string()),
229 requested_command: None,
230 };
231 let prepared = crate::sandbox::SandboxManager::new().prepare(&spec);
232 if pty_lacks_required_sandbox(&policy, prepared.sandbox_type) {
233 return Err(
234 "terminal PTY tools cannot run unsandboxed under a narrowed filesystem posture; use Full Access / ExternalSandbox or enable seatbelt/bwrap"
235 .to_string(),
236 );
237 }
238 if prepared.command.is_empty() {
239 return Err("sandbox prepare returned an empty PTY command".to_string());
240 }
241 let confined = !matches!(prepared.sandbox_type, crate::sandbox::SandboxType::None);
242 Ok((prepared, confined))
243 }
244
245 #[cfg(unix)]
246 fn create_session(
247 name: &str,
248 workspace: &std::path::Path,
249 policy: crate::sandbox::SandboxPolicy,
250 ) -> Result<SharedSession, String> {
251 // Unit tests exercise the persistent-PTY contract, not a developer's
252 // interactive shell startup files. Keep their deadlines deterministic and
253 // avoid racing process-global HOME/SHELL overrides from parallel tests.
254 #[cfg(test)]
255 let shell = "/bin/sh".to_string();
256 #[cfg(not(test))]
257 let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string());
258 let workspace = workspace
259 .canonicalize()
260 .unwrap_or_else(|_| workspace.to_path_buf());
261 let durable_path = durable_path(name, &workspace)?;
262 let previous = load_durable(&durable_path).map(|mut record| {
263 // A persisted shell is historical evidence only. A random per-process
264 // nonce makes PID reuse irrelevant and deliberately forbids reattach.
265 record.state = DurableTerminalState::StaleLost;
266 record.updated_at = chrono::Utc::now().to_rfc3339();
267 Box::new(record)
268 });
269 let pty = portable_pty::native_pty_system();
270 let pair = pty
271 .openpty(portable_pty::PtySize {
272 rows: 24,
273 cols: 120,
274 pixel_width: 0,
275 pixel_height: 0,
276 })
277 .map_err(|e| format!("failed to open PTY: {e}"))?;
278
279 let (prepared, sandbox_confined) = prepare_pty_shell(&shell, &workspace, policy)?;
280 let mut command = portable_pty::CommandBuilder::new(&prepared.command[0]);
281 for arg in &prepared.command[1..] {
282 command.arg(arg);
283 }
284 command.cwd(&prepared.cwd);
285 for (key, value) in &prepared.env {
286 command.env(key, value);
287 }
288 let child = pair
289 .slave
290 .spawn_command(command)
291 .map_err(|e| format!("failed to start shell {shell}: {e}"))?;
292 drop(pair.slave);
293
294 let reader = pair
295 .master
296 .try_clone_reader()
297 .map_err(|e| format!("failed to read PTY: {e}"))?;
298 let writer = pair
299 .master
300 .take_writer()
301 .map_err(|e| format!("failed to write PTY: {e}"))?;
302 let output = Arc::new(Mutex::new(OutputBuffer::default()));
303 let reader_output = Arc::clone(&output);
304 std::thread::spawn(move || {
305 let mut reader = reader;
306 let mut buf = [0u8; 8192];
307 loop {
308 match std::io::Read::read(&mut reader, &mut buf) {
309 Ok(0) | Err(_) => break,
310 Ok(n) => {
311 if let Ok(mut output) = reader_output.lock() {
312 output.append(&buf[..n]);
313 }
314 }
315 }
316 }
317 });
318
319 let durable = DurableTerminalRecord {
320 schema_version: 1,
321 session_id: Uuid::new_v4().to_string(),
322 runtime_nonce: runtime_nonce().to_string(),
323 process_id: std::process::id(),
324 workspace: workspace.clone(),
325 name: name.to_string(),
326 shell,
327 last_known_cwd: workspace,
328 environment_summary: EnvironmentSummary {
329 term: std::env::var("TERM").ok().filter(|value| !value.trim().is_empty()),
330 virtual_env_active: std::env::var_os("VIRTUAL_ENV").is_some(),
331 conda_env_active: std::env::var_os("CONDA_PREFIX").is_some(),
332 nix_shell_active: std::env::var_os("IN_NIX_SHELL").is_some(),
333 note: "Values and secrets are not persisted; in-shell environment changes are process-local."
334 .to_string(),
335 },
336 state: DurableTerminalState::Idle,
337 updated_at: chrono::Utc::now().to_rfc3339(),
338 previous,
339 };
340 persist_durable(&durable_path, &durable)?;
341
342 Ok(Arc::new(Mutex::new(TerminalSession {
343 writer: Arc::new(Mutex::new(writer)),
344 child,
345 output,
346 read_cursor: 0,
347 command: None,
348 durable,
349 durable_path,
350 sandbox_confined,
351 })))
352 }
353
354 #[cfg(unix)]
355 fn get_or_create(
356 name: &str,
357 workspace: &std::path::Path,
358 policy: crate::sandbox::SandboxPolicy,
359 ) -> Result<SharedSession, String> {
360 let key = session_key(name, workspace);
361 let mut registry = sessions()
362 .lock()
363 .map_err(|_| "terminal session registry lock poisoned".to_string())?;
364 if let Some(session) = registry.get(&key) {
365 let confined = session
366 .lock()
367 .ok()
368 .map(|guard| guard.sandbox_confined)
369 .unwrap_or(false);
370 if policy.should_sandbox() && !confined {
371 return Err(
372 "existing PTY session was started without a sandbox; start a new session name under the current posture or use Full Access"
373 .to_string(),
374 );
375 }
376 return Ok(Arc::clone(session));
377 }
378 let session = create_session(name, workspace, policy)?;
379 registry.insert(key, Arc::clone(&session));
380 Ok(session)
381 }
382
383 #[cfg(unix)]
384 fn find(name: &str, workspace: &Path) -> Result<SharedSession, String> {
385 let live = sessions()
386 .lock()
387 .map_err(|_| "terminal session registry lock poisoned".to_string())?
388 .get(&session_key(name, workspace))
389 .cloned();
390 if let Some(live) = live {
391 return Ok(live);
392 }
393 if let Ok(path) = durable_path(name, workspace)
394 && let Some(mut record) = load_durable(&path)
395 {
396 record.state = DurableTerminalState::StaleLost;
397 record.updated_at = chrono::Utc::now().to_rfc3339();
398 let _ = persist_durable(&path, &record);
399 return Err(format!(
400 "terminal session '{name}' is stale/lost after restart (last cwd: {}); run terminal/run with this name to start a replacement while preserving the historical summary",
401 record.last_known_cwd.display()
402 ));
403 }
404 Err(format!(
405 "terminal session '{name}' does not exist in workspace {}",
406 workspace.display()
407 ))
408 }
409
410 #[cfg(unix)]
411 fn write_bytes(session: &TerminalSession, bytes: &[u8]) -> Result<(), String> {
412 let mut writer = session
413 .writer
414 .lock()
415 .map_err(|_| "terminal PTY writer lock poisoned".to_string())?;
416 writer
417 .write_all(bytes)
418 .map_err(|e| format!("PTY write failed: {e}"))?;
419 writer.flush().map_err(|e| format!("PTY flush failed: {e}"))
420 }
421
422 #[cfg(unix)]
423 fn output_snapshot(session: &TerminalSession) -> String {
424 session
425 .output
426 .lock()
427 .map(|output| output.text())
428 .unwrap_or_default()
429 }
430
431 #[cfg(unix)]
432 fn take_output(session: &mut TerminalSession) -> String {
433 let Ok(output) = session.output.lock() else {
434 return String::new();
435 };
436 let retained_start = output.total.saturating_sub(output.bytes.len() as u64);
437 let start = session.read_cursor.max(retained_start);
438 let skip = usize::try_from(start.saturating_sub(retained_start)).unwrap_or(usize::MAX);
439 let bytes = output.bytes.iter().skip(skip).copied().collect::<Vec<_>>();
440 session.read_cursor = output.total;
441 String::from_utf8_lossy(&bytes).into_owned()
442 }
443
444 #[cfg(unix)]
445 fn prune_output(input: &str) -> String {
446 if input.len() <= OUTPUT_LIMIT {
447 return input.to_string();
448 }
449 let head = OUTPUT_LIMIT / 3;
450 let tail = OUTPUT_LIMIT - head;
451 let head_end = input
452 .char_indices()
453 .find(|(index, _)| *index >= head)
454 .map_or(input.len(), |(index, _)| index);
455 let tail_start = input
456 .char_indices()
457 .rev()
458 .find(|(index, _)| input.len() - *index <= tail)
459 .map_or(0, |(index, _)| index);
460 format!(
461 "{}\n… [output truncated: {} bytes omitted] …\n{}",
462 &input[..head_end],
463 input.len() - OUTPUT_LIMIT,
464 &input[tail_start..]
465 )
466 }
467
468 #[cfg(unix)]
469 fn completion(session: &TerminalSession) -> Option<(i32, String)> {
470 let state = session.command.as_ref()?;
471 let output = output_snapshot(session);
472 let marker = format!("\n{}:", state.marker);
473 let line = output
474 .rsplit(&marker)
475 .next()
476 .and_then(|tail| tail.lines().next())?;
477 let (status, cwd) = line.split_once(':')?;
478 Some((status.parse().ok()?, cwd.to_string()))
479 }
480
481 #[cfg(unix)]
482 fn start_command(session: &mut TerminalSession, command: &str) -> Result<(), String> {
483 if session.command.is_some() && completion(session).is_none() {
484 return Err("terminal session already has a running foreground command".to_string());
485 }
486 let marker = format!("__CODEWHALE_TERM_{}__", Uuid::new_v4().simple());
487 // The command must run in the CURRENT shell — a subshell would discard
488 // exactly the state (cd, exports, functions, activated envs) this tool
489 // exists to preserve (EXEC-001). The sentinel line is typed after the
490 // command; the tty line discipline holds it until the foreground command
491 // finishes reading input.
492 let wrapped = format!(
493 "{command}\n__cw_status=$?; printf '\\n{marker}:%s:%s\\n' \"$__cw_status\" \"$PWD\"\n"
494 );
495 write_bytes(session, wrapped.as_bytes())?;
496 session.command = Some(CommandState { marker });
497 session.durable.state = DurableTerminalState::Running;
498 session.durable.updated_at = chrono::Utc::now().to_rfc3339();
499 persist_durable(&session.durable_path, &session.durable)?;
500 Ok(())
501 }
502
503 #[cfg(unix)]
504 fn write_completion_sentinel(
505 session: &TerminalSession,
506 marker: &str,
507 status: i32,
508 ) -> Result<(), String> {
509 let sentinel =
510 format!("__cw_status={status}; printf '\\n{marker}:%s:%s\\n' \"$__cw_status\" \"$PWD\"\n");
511 write_bytes(session, sentinel.as_bytes())
512 }
513
514 #[cfg(unix)]
515 #[cfg(test)]
516 fn wait_session(session: &mut TerminalSession, timeout: Duration) -> (Option<(i32, String)>, bool) {
517 let deadline = Instant::now() + timeout;
518 loop {
519 if let Some(done) = completion(session) {
520 return (Some(done), false);
521 }
522 if Instant::now() >= deadline {
523 return (None, true);
524 }
525 std::thread::sleep(Duration::from_millis(25));
526 }
527 }
528
529 /// Wait without monopolizing the per-session lock. `terminal/send` and
530 /// `terminal/cancel` must be able to acquire the lock while a foreground
531 /// command is active; otherwise interactive input and cancellation deadlock
532 /// behind the waiter.
533 #[cfg(unix)]
534 fn wait_shared_session(
535 session: &SharedSession,
536 timeout: Duration,
537 ) -> Result<(Option<(i32, String)>, bool), ToolError> {
538 let deadline = Instant::now() + timeout;
539 loop {
540 let done = {
541 let session = session
542 .lock()
543 .map_err(|_| ToolError::execution_failed("terminal session lock poisoned"))?;
544 completion(&session)
545 };
546 if let Some(done) = done {
547 return Ok((Some(done), false));
548 }
549 if Instant::now() >= deadline {
550 return Ok((None, true));
551 }
552 std::thread::sleep(Duration::from_millis(25));
553 }
554 }
555
556 /// Interrupt a foreground command and wait until the persistent shell confirms
557 /// that it is ready again.
558 ///
559 /// Canonical PTYs may flush queued input when they process ETX. Resubmit the
560 /// completion sentinel until the shell acknowledges it so a busy host cannot
561 /// strand the session merely because the one post-interrupt write raced that
562 /// flush.
563 #[cfg(unix)]
564 fn cancel_shared_session(session: &SharedSession) -> Result<(i32, String), ToolError> {
565 let marker = {
566 let session = session
567 .lock()
568 .map_err(|_| ToolError::execution_failed("terminal session lock poisoned"))?;
569 if session.command.is_none() || completion(&session).is_some() {
570 return Err(ToolError::execution_failed(
571 "terminal session has no running foreground command",
572 ));
573 }
574 write_bytes(&session, &[3]).map_err(ToolError::execution_failed)?;
575 session
576 .command
577 .as_ref()
578 .expect("running command checked above")
579 .marker
580 .clone()
581 };
582 let deadline = Instant::now() + CANCEL_CONFIRM_TIMEOUT;
583
584 loop {
585 std::thread::sleep(CANCEL_SENTINEL_RETRY_INTERVAL);
586 let session = session
587 .lock()
588 .map_err(|_| ToolError::execution_failed("terminal session lock poisoned"))?;
589 if let Some(done) = completion(&session) {
590 return Ok(done);
591 }
592 if Instant::now() >= deadline {
593 return Err(ToolError::execution_failed(
594 "terminal interrupt was sent, but cancellation was not confirmed within 2 seconds",
595 ));
596 }
597 write_completion_sentinel(&session, &marker, 130).map_err(ToolError::execution_failed)?;
598 }
599 }
600
601 #[cfg(unix)]
602 fn session_result(
603 session: &mut TerminalSession,
604 done: Option<(i32, String)>,
605 timed_out: bool,
606 ) -> ToolResult {
607 let output = prune_output(&take_output(session));
608 let finished = done.is_some();
609 let (exit_code, cwd) = done.map_or((None, String::new()), |(code, cwd)| (Some(code), cwd));
610 let status = if timed_out {
611 "timed_out"
612 } else if finished {
613 "completed"
614 } else {
615 "running"
616 };
617 if !cwd.is_empty() {
618 session.durable.last_known_cwd = PathBuf::from(&cwd);
619 }
620 session.durable.state = if timed_out || !finished {
621 DurableTerminalState::Running
622 } else if exit_code == Some(0) {
623 DurableTerminalState::Idle
624 } else if exit_code == Some(130) {
625 DurableTerminalState::Canceled
626 } else {
627 DurableTerminalState::Failed
628 };
629 session.durable.updated_at = chrono::Utc::now().to_rfc3339();
630 let persistence_error = persist_durable(&session.durable_path, &session.durable).err();
631 let previous = session.durable.previous.as_deref().map(|record| {
632 json!({
633 "session_id": record.session_id,
634 "state": record.state,
635 "last_known_cwd": record.last_known_cwd,
636 "updated_at": record.updated_at,
637 })
638 });
639 ToolResult {
640 content: output,
641 // A successful send while the command is still running is itself a
642 // successful tool operation. Completed commands still report their
643 // real exit status, and timeouts remain unsuccessful.
644 success: !timed_out && (!finished || exit_code == Some(0)),
645 metadata: Some(json!({
646 "status": status,
647 "exit_code": exit_code,
648 "cwd": cwd,
649 "session_persistent": true,
650 "durability": "live shell is process-local; identity and last-known summary persist",
651 "terminal_session_id": session.durable.session_id,
652 "terminal_state": session.durable.state,
653 "state_path": session.durable_path,
654 "previous_session": previous,
655 "persistence_error": persistence_error,
656 })),
657 }
658 }
659
660 #[cfg(unix)]
661 fn session_name(input: &serde_json::Value, required: bool) -> Result<&str, ToolError> {
662 match input.get("session").and_then(serde_json::Value::as_str) {
663 Some(name) if !name.is_empty() => Ok(name),
664 Some(_) => Err(ToolError::execution_failed("session must not be empty")),
665 None if required => Err(ToolError::missing_field("session")),
666 None => Ok("term-1"),
667 }
668 }
669
670 #[cfg(unix)]
671 fn timeout_secs(input: &serde_json::Value, key: &str) -> Result<Duration, ToolError> {
672 Ok(Duration::from_secs(
673 optional_u64(input, key, DEFAULT_TIMEOUT_SECS)?.clamp(1, MAX_TIMEOUT_SECS),
674 ))
675 }
676
677 fn shell_allowed(context: &ToolContext, name: &str) -> Result<(), ToolError> {
678 crate::core::engine::tool_catalog::enforce_tool_denial(context, name, &json!({}))?;
679 if matches!(name, "terminal/run" | "terminal/send" | "terminal/reset")
680 && context.shell_policy != crate::worker_profile::ShellPolicy::Full
681 {
682 return Err(ToolError::permission_denied(
683 "Persistent terminal execution and input require full shell permission.",
684 ));
685 }
686 if context.shell_policy.allows_shell() {
687 Ok(())
688 } else {
689 Err(ToolError::execution_failed(
690 "Shell tools are disabled by the active permission profile.",
691 ))
692 }
693 }
694
695 #[cfg(not(unix))]
696 fn unsupported() -> ToolResult {
697 ToolResult::error("Stateful terminal sessions are currently supported on Unix only.")
698 }
699
700 macro_rules! terminal_tool_common {
701 ($name:literal, $description:literal) => {
702 fn name(&self) -> &'static str {
703 $name
704 }
705 fn description(&self) -> &'static str {
706 $description
707 }
708 fn capabilities(&self) -> Vec<ToolCapability> {
709 vec![
710 ToolCapability::ExecutesCode,
711 ToolCapability::RequiresApproval,
712 ]
713 }
714 fn approval_requirement(&self) -> ApprovalRequirement {
715 ApprovalRequirement::Required
716 }
717 };
718 }
719
720 pub struct TerminalRunTool;
721 #[async_trait]
722 impl ToolSpec for TerminalRunTool {
723 terminal_tool_common!(
724 "terminal/run",
725 "Run a command in a persistent PTY shell session. cd, exports, shell functions, and activated environments persist across calls in this process. Identity and a non-secret last-known summary persist across restarts; prior shells are surfaced as stale/lost and are never reattached."
726 );
727 fn input_schema(&self) -> serde_json::Value {
728 json!({"type":"object","properties":{"command":{"type":"string"},"session":{"type":"string","default":"term-1"},"timeout_secs":{"type":"integer","default":120}},"required":["command"]})
729 }
730 async fn execute(
731 &self,
732 input: serde_json::Value,
733 context: &ToolContext,
734 ) -> Result<ToolResult, ToolError> {
735 shell_allowed(context, self.name())?;
736 #[cfg(unix)]
737 {
738 let command = required_str(&input, "command")?.to_string();
739 let name = session_name(&input, false)?.to_string();
740 let session = get_or_create(&name, &context.workspace, pty_sandbox_policy(context))
741 .map_err(ToolError::execution_failed)?;
742 let timeout = timeout_secs(&input, "timeout_secs")?;
743 return tokio::task::spawn_blocking(move || {
744 {
745 let mut session = session.lock().map_err(|_| {
746 ToolError::execution_failed("terminal session lock poisoned")
747 })?;
748 start_command(&mut session, &command).map_err(ToolError::execution_failed)?;
749 }
750 let (done, timed_out) = wait_shared_session(&session, timeout)?;
751 let mut session = session
752 .lock()
753 .map_err(|_| ToolError::execution_failed("terminal session lock poisoned"))?;
754 Ok(session_result(&mut session, done, timed_out))
755 })
756 .await
757 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
758 }
759 #[cfg(not(unix))]
760 {
761 let _ = input;
762 Ok(unsupported())
763 }
764 }
765 }
766
767 pub struct TerminalSendTool;
768 #[async_trait]
769 impl ToolSpec for TerminalSendTool {
770 terminal_tool_common!(
771 "terminal/send",
772 "Send raw input to a live persistent terminal session. Use a literal ETX control byte to interrupt an interactive process. A prior-process shell is reported as stale/lost rather than reattached."
773 );
774 fn input_schema(&self) -> serde_json::Value {
775 json!({"type":"object","properties":{"session":{"type":"string"},"text":{"type":"string"},"wait_ms":{"type":"integer","default":250}},"required":["session","text"]})
776 }
777 async fn execute(
778 &self,
779 input: serde_json::Value,
780 context: &ToolContext,
781 ) -> Result<ToolResult, ToolError> {
782 shell_allowed(context, self.name())?;
783 #[cfg(unix)]
784 {
785 let name = session_name(&input, true)?.to_string();
786 let text = required_str(&input, "text")?.as_bytes().to_vec();
787 let session = find(&name, &context.workspace).map_err(ToolError::execution_failed)?;
788 let wait = Duration::from_millis(optional_u64(&input, "wait_ms", 250)?.min(60_000));
789 return tokio::task::spawn_blocking(move || {
790 let mut session = session
791 .lock()
792 .map_err(|_| ToolError::execution_failed("terminal session lock poisoned"))?;
793 write_bytes(&session, &text).map_err(ToolError::execution_failed)?;
794 std::thread::sleep(wait);
795 let done = completion(&session);
796 Ok(session_result(&mut session, done, false))
797 })
798 .await
799 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
800 }
801 #[cfg(not(unix))]
802 {
803 let _ = input;
804 Ok(unsupported())
805 }
806 }
807 }
808
809 pub struct TerminalWaitTool;
810 #[async_trait]
811 impl ToolSpec for TerminalWaitTool {
812 terminal_tool_common!(
813 "terminal/wait",
814 "Wait for the current foreground command in a live persistent terminal session and return buffered output. A prior-process shell is reported as stale/lost rather than reattached."
815 );
816 fn input_schema(&self) -> serde_json::Value {
817 json!({"type":"object","properties":{"session":{"type":"string"},"timeout_secs":{"type":"integer","default":120}},"required":["session"]})
818 }
819 async fn execute(
820 &self,
821 input: serde_json::Value,
822 context: &ToolContext,
823 ) -> Result<ToolResult, ToolError> {
824 shell_allowed(context, self.name())?;
825 #[cfg(unix)]
826 {
827 let name = session_name(&input, true)?.to_string();
828 let session = find(&name, &context.workspace).map_err(ToolError::execution_failed)?;
829 let timeout = timeout_secs(&input, "timeout_secs")?;
830 return tokio::task::spawn_blocking(move || {
831 let (done, timed_out) = wait_shared_session(&session, timeout)?;
832 let mut session = session
833 .lock()
834 .map_err(|_| ToolError::execution_failed("terminal session lock poisoned"))?;
835 Ok(session_result(&mut session, done, timed_out))
836 })
837 .await
838 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
839 }
840 #[cfg(not(unix))]
841 {
842 let _ = input;
843 Ok(unsupported())
844 }
845 }
846 }
847
848 pub struct TerminalCancelTool;
849 #[async_trait]
850 impl ToolSpec for TerminalCancelTool {
851 terminal_tool_common!(
852 "terminal/cancel",
853 "Interrupt the running foreground command with ETX. The live terminal session survives and can be reused; its non-secret summary persists."
854 );
855 fn input_schema(&self) -> serde_json::Value {
856 json!({"type":"object","properties":{"session":{"type":"string"}},"required":["session"]})
857 }
858 async fn execute(
859 &self,
860 input: serde_json::Value,
861 context: &ToolContext,
862 ) -> Result<ToolResult, ToolError> {
863 shell_allowed(context, self.name())?;
864 #[cfg(unix)]
865 {
866 let name = session_name(&input, true)?.to_string();
867 let session = find(&name, &context.workspace).map_err(ToolError::execution_failed)?;
868 return tokio::task::spawn_blocking(move || {
869 let done = cancel_shared_session(&session)?;
870 let mut session = session
871 .lock()
872 .map_err(|_| ToolError::execution_failed("terminal session lock poisoned"))?;
873 let mut result = session_result(&mut session, Some(done), false);
874 result.success = true;
875 if let Some(metadata) = result.metadata.as_mut() {
876 metadata["status"] = json!("canceled");
877 metadata["canceled"] = json!(true);
878 }
879 Ok(result)
880 })
881 .await
882 .map_err(|e| ToolError::execution_failed(e.to_string()))?;
883 }
884 #[cfg(not(unix))]
885 {
886 let _ = input;
887 Ok(unsupported())
888 }
889 }
890 }
891
892 pub struct TerminalResetTool;
893 #[async_trait]
894 impl ToolSpec for TerminalResetTool {
895 terminal_tool_common!(
896 "terminal/reset",
897 "Kill and recreate a persistent terminal session with a fresh environment. This loses live cd, exports, functions, activated environments, and running work while retaining the prior historical summary."
898 );
899 fn input_schema(&self) -> serde_json::Value {
900 json!({"type":"object","properties":{"session":{"type":"string"}},"required":["session"]})
901 }
902 async fn execute(
903 &self,
904 input: serde_json::Value,
905 context: &ToolContext,
906 ) -> Result<ToolResult, ToolError> {
907 shell_allowed(context, self.name())?;
908 #[cfg(unix)]
909 {
910 let name = session_name(&input, true)?.to_string();
911 let old = find(&name, &context.workspace).map_err(ToolError::execution_failed)?;
912 let workspace = context.workspace.clone();
913 let policy = pty_sandbox_policy(context);
914 return tokio::task::spawn_blocking(move || {
915 if let Ok(mut old) = old.lock() { let _ = old.child.kill(); }
916 if let Ok(mut old) = old.lock() {
917 old.durable.state = DurableTerminalState::Reset;
918 old.durable.updated_at = chrono::Utc::now().to_rfc3339();
919 let _ = persist_durable(&old.durable_path, &old.durable);
920 }
921 let fresh = create_session(&name, &workspace, policy)
922 .map_err(ToolError::execution_failed)?;
923 sessions().lock().map_err(|_| ToolError::execution_failed("terminal session registry lock poisoned"))?.insert(session_key(&name, &workspace), fresh);
924 Ok(ToolResult { content: format!("Reset terminal session '{name}'. Lost shell state and any running command."), success: true, metadata: Some(json!({"session":name,"reset":true,"lost_state":["cwd","environment","functions","activated environments","running command"]})) })
925 }).await.map_err(|e| ToolError::execution_failed(e.to_string()))?;
926 }
927 #[cfg(not(unix))]
928 {
929 let _ = input;
930 Ok(unsupported())
931 }
932 }
933 }
934
935 #[cfg(all(test, unix))]
936 mod tests {
937 use super::*;
938
939 fn fresh(name: &str) -> SharedSession {
940 let session = get_or_create(
941 name,
942 std::path::Path::new("/tmp"),
943 crate::sandbox::SandboxPolicy::DangerFullAccess,
944 )
945 .unwrap();
946 let mut session_guard = session.lock().unwrap();
947 let _ = session_guard.child.kill();
948 drop(session_guard);
949 let replacement = create_session(
950 name,
951 std::path::Path::new("/tmp"),
952 crate::sandbox::SandboxPolicy::DangerFullAccess,
953 )
954 .unwrap();
955 sessions().lock().unwrap().insert(
956 session_key(name, Path::new("/tmp")),
957 Arc::clone(&replacement),
958 );
959 replacement
960 }
961
962 fn run(session: &SharedSession, command: &str, timeout: Duration) -> ToolResult {
963 let mut session = session.lock().unwrap();
964 start_command(&mut session, command).unwrap();
965 let (done, timed_out) = wait_session(&mut session, timeout);
966 session_result(&mut session, done, timed_out)
967 }
968
969 #[test]
970 #[cfg(unix)]
971 fn cd_persists_between_runs() {
972 let session = fresh("test-cd");
973 let _ = run(
974 &session,
975 "mkdir -p /tmp/cw-term-cd-proof && cd /tmp/cw-term-cd-proof",
976 Duration::from_secs(3),
977 );
978 // A separate run must still be inside the directory — this is the
979 // whole point of the stateful session (EXEC-001).
980 let result = run(&session, "pwd", Duration::from_secs(3));
981 assert!(result.content.contains("cw-term-cd-proof"), "{}", {
982 &result.content
983 });
984 }
985
986 #[test]
987 #[cfg(unix)]
988 fn export_persists_and_sessions_are_isolated() {
989 let one = fresh("test-env-one");
990 let two = fresh("test-env-two");
991 let _ = run(&one, "export CW_TERM_TEST=present", Duration::from_secs(3));
992 assert!(
993 run(&one, "printf %s $CW_TERM_TEST", Duration::from_secs(3))
994 .content
995 .contains("present")
996 );
997 assert!(
998 !run(
999 &two,
1000 "printf %s ${CW_TERM_TEST-unset}",
1001 Duration::from_secs(3)
1002 )
1003 .content
1004 .contains("present")
1005 );
1006 }
1007
1008 #[test]
1009 #[cfg(unix)]
1010 fn reset_replaces_shell_environment() {
1011 let session = fresh("test-reset");
1012 let _ = run(
1013 &session,
1014 "export CW_TERM_RESET=present",
1015 Duration::from_secs(3),
1016 );
1017 assert!(
1018 run(&session, "printf %s $CW_TERM_RESET", Duration::from_secs(3))
1019 .content
1020 .contains("present")
1021 );
1022 let _ = session.lock().unwrap().child.kill();
1023 let replacement = create_session(
1024 "test-reset",
1025 std::path::Path::new("/tmp"),
1026 crate::sandbox::SandboxPolicy::DangerFullAccess,
1027 )
1028 .unwrap();
1029 sessions().lock().unwrap().insert(
1030 session_key("test-reset", Path::new("/tmp")),
1031 Arc::clone(&replacement),
1032 );
1033 assert!(
1034 !run(
1035 &replacement,
1036 "printf %s ${CW_TERM_RESET-unset}",
1037 Duration::from_secs(3)
1038 )
1039 .content
1040 .contains("present")
1041 );
1042 }
1043
1044 #[test]
1045 #[cfg(unix)]
1046 fn timeout_leaves_session_alive() {
1047 let session = fresh("test-timeout");
1048 let result = run(
1049 &session,
1050 "printf before; sleep 2",
1051 Duration::from_millis(100),
1052 );
1053 assert_eq!(result.metadata.as_ref().unwrap()["status"], "timed_out");
1054 let result = {
1055 let mut session_guard = session.lock().unwrap();
1056 let (done, timed_out) = wait_session(&mut session_guard, Duration::from_secs(3));
1057 session_result(&mut session_guard, done, timed_out)
1058 };
1059 assert_eq!(result.metadata.as_ref().unwrap()["status"], "completed");
1060 let result = run(&session, "printf after", Duration::from_secs(3));
1061 assert!(result.content.contains("after"));
1062 }
1063
1064 #[test]
1065 #[cfg(unix)]
1066 fn cancel_interrupts_sleep_and_session_survives() {
1067 let session = fresh("test-cancel");
1068 // Prove the interactive shell is initialized before interrupting it.
1069 // A SIGINT delivered while `sh -i` is still starting can kill the
1070 // shell itself, which later surfaces as EIO on the next PTY write
1071 // (hosted macOS run 34716492759 under load).
1072 assert!(
1073 run(&session, "printf ready", Duration::from_secs(10))
1074 .content
1075 .contains("ready")
1076 );
1077 let worker = Arc::clone(&session);
1078 {
1079 let mut guard = worker.lock().unwrap();
1080 // The quoted split keeps the marker out of the echoed command
1081 // line, so seeing it proves the shell reached this command.
1082 start_command(&mut guard, "printf 'st''arted'; sleep 10").unwrap();
1083 }
1084 let deadline = Instant::now() + Duration::from_secs(10);
1085 loop {
1086 let started = output_snapshot(&session.lock().unwrap()).contains("started");
1087 if started {
1088 break;
1089 }
1090 assert!(
1091 Instant::now() < deadline,
1092 "shell never reached the sleep command"
1093 );
1094 std::thread::sleep(Duration::from_millis(25));
1095 }
1096 let handle = std::thread::spawn(move || {
1097 let (done, timed_out) = wait_shared_session(&worker, Duration::from_secs(30)).unwrap();
1098 let mut guard = worker.lock().unwrap();
1099 session_result(&mut guard, done, timed_out)
1100 });
1101 std::thread::sleep(Duration::from_millis(150));
1102 let done = cancel_shared_session(&session).unwrap();
1103 let result = handle.join().unwrap();
1104 assert_eq!(done.0, 130);
1105 assert_eq!(result.metadata.as_ref().unwrap()["exit_code"], 130);
1106 assert!(
1107 run(&session, "printf alive", Duration::from_secs(3))
1108 .content
1109 .contains("alive")
1110 );
1111 }
1112
1113 #[test]
1114 fn narrowed_posture_without_a_backend_fails_closed() {
1115 assert!(pty_lacks_required_sandbox(
1116 &crate::sandbox::SandboxPolicy::ReadOnly,
1117 crate::sandbox::SandboxType::None,
1118 ));
1119 assert!(!pty_lacks_required_sandbox(
1120 &crate::sandbox::SandboxPolicy::DangerFullAccess,
1121 crate::sandbox::SandboxType::None,
1122 ));
1123 }
1124
1125 #[test]
1126 fn full_access_prepares_an_unsandboxed_pty() {
1127 let (prepared, confined) = prepare_pty_shell(
1128 "/bin/sh",
1129 Path::new("/tmp"),
1130 crate::sandbox::SandboxPolicy::DangerFullAccess,
1131 )
1132 .expect("full access may start a PTY");
1133 assert!(!confined);
1134 assert_eq!(prepared.command[0], "/bin/sh");
1135 assert_eq!(prepared.command[1], "-i");
1136 }
1137
1138 #[test]
1139 #[cfg(unix)]
1140 fn session_names_are_scoped_to_workspace() {
1141 let first_workspace = tempfile::tempdir().unwrap();
1142 let second_workspace = tempfile::tempdir().unwrap();
1143 let first = get_or_create(
1144 "shared-name",
1145 first_workspace.path(),
1146 crate::sandbox::SandboxPolicy::DangerFullAccess,
1147 )
1148 .unwrap();
1149 let second = get_or_create(
1150 "shared-name",
1151 second_workspace.path(),
1152 crate::sandbox::SandboxPolicy::DangerFullAccess,
1153 )
1154 .unwrap();
1155 assert!(!Arc::ptr_eq(&first, &second));
1156 assert!(find("shared-name", first_workspace.path()).is_ok());
1157 assert!(find("shared-name", second_workspace.path()).is_ok());
1158 assert!(find("shared-name", Path::new("/tmp")).is_err());
1159 }
1160
1161 #[test]
1162 #[cfg(unix)]
1163 fn durable_summary_marks_prior_process_shell_stale_and_preserves_history() {
1164 let workspace = tempfile::tempdir().unwrap();
1165 let canonical_workspace = workspace.path().canonicalize().unwrap();
1166 let name = format!("restart-proof-{}", Uuid::new_v4());
1167 let first = create_session(
1168 &name,
1169 workspace.path(),
1170 crate::sandbox::SandboxPolicy::DangerFullAccess,
1171 )
1172 .unwrap();
1173 let first_record = first.lock().unwrap().durable.clone();
1174 assert_eq!(first_record.state, DurableTerminalState::Idle);
1175 assert_eq!(first_record.last_known_cwd, canonical_workspace);
1176 assert!(!first_record.session_id.is_empty());
1177
1178 // Removing only the process-local registry entry models a restart.
1179 // The durable record remains, but cannot be used to reattach.
1180 sessions()
1181 .lock()
1182 .unwrap()
1183 .remove(&session_key(&name, workspace.path()));
1184 let stale = match find(&name, workspace.path()) {
1185 Ok(_) => panic!("persisted session must not be reattached"),
1186 Err(error) => error,
1187 };
1188 assert!(stale.contains("stale/lost"), "{stale}");
1189 assert!(stale.contains("start a replacement"), "{stale}");
1190
1191 let replacement = create_session(
1192 &name,
1193 workspace.path(),
1194 crate::sandbox::SandboxPolicy::DangerFullAccess,
1195 )
1196 .unwrap();
1197 let replacement = replacement.lock().unwrap();
1198 assert_ne!(replacement.durable.session_id, first_record.session_id);
1199 let previous = replacement.durable.previous.as_deref().unwrap();
1200 assert_eq!(previous.session_id, first_record.session_id);
1201 assert_eq!(previous.state, DurableTerminalState::StaleLost);
1202 assert_eq!(previous.last_known_cwd, canonical_workspace);
1203 assert_ne!(replacement.durable.runtime_nonce, "");
1204 }
1205
1206 #[test]
1207 #[cfg(unix)]
1208 fn output_is_capped_with_notice() {
1209 let session = fresh("test-output-cap");
1210 let result = run(&session, "yes x | head -n 100000", Duration::from_secs(3));
1211 assert!(result.content.len() <= OUTPUT_LIMIT + 100);
1212 assert!(result.content.contains("output truncated"));
1213 }
1214 }
1215
1215 lines RUST