1use hashbrown::HashMap;
2use std::io::Read;
3use std::path::{Path, PathBuf};
4use std::sync::{
5 Arc, Weak,
6 atomic::{AtomicBool, AtomicU64, Ordering},
7 mpsc,
8};
9use std::thread;
10use std::time::{Duration, Instant};
11
12use anyhow::{Context, Result, anyhow};
13use chrono::Utc;
14use parking_lot::{Mutex, RwLock};
15use portable_pty::{CommandBuilder, PtySize, native_pty_system};
16use sha2::{Digest, Sha256};
17use shell_words::join;
18use tokio::io::AsyncWriteExt;
19use tokio::sync::Mutex as TokioMutex;
20use tokio::sync::mpsc as tokio_mpsc;
21use tracing::{debug, info, warn};
22
23use super::command_utils::{
24 is_long_running_command, is_long_running_command_string, is_sandbox_wrapper_program, is_shell_program,
25};
26use super::formatting::sanitize_session_id;
27use super::manager_utils::{clamp_timeout, exit_status_code, set_command_environment_with_sandbox};
28
29use super::screen_backend::PtyScreenState;
30use super::scrollback::PtyScrollback;
31use super::session::PtySessionHandle;
32use super::types::{PtyCommandRequest, PtyCommandResult};
33
34use once_cell::sync::Lazy;
35
36static WORKSPACE_COMMAND_LOCKS: Lazy<Mutex<HashMap<PathBuf, Weak<tokio::sync::Mutex<()>>>>> =
45 Lazy::new(|| Mutex::new(HashMap::new()));
46
47fn get_command_lock(workspace_root: &Path) -> Arc<tokio::sync::Mutex<()>> {
49 let mut locks = WORKSPACE_COMMAND_LOCKS.lock();
50 let key = workspace_root.to_path_buf();
51 if let Some(lock) = locks.get(&key).and_then(Weak::upgrade) {
52 return lock;
53 }
54 let lock = Arc::new(tokio::sync::Mutex::new(()));
55 locks.insert(key, Arc::downgrade(&lock));
56 lock
57}
58
59const THREAD_JOIN_GRACE_PERIOD_MS: u64 = 500;
61
62const PTY_OUTPUT_TRUNCATED_MARKER: &[u8] = b"\n[... output truncated: capture limit reached ...]";
63
64fn read_pty_output_capped(mut reader: impl Read, max_bytes: usize) -> Result<Vec<u8>> {
66 let mut buffer = [0u8; 4096];
67 let mut collected = Vec::new();
68 let mut truncated = false;
69
70 loop {
71 match reader.read(&mut buffer) {
72 Ok(0) => break,
73 Ok(bytes_read) => {
74 let room = max_bytes.saturating_sub(collected.len());
75 if bytes_read > room {
76 truncated = true;
77 }
78 collected.extend_from_slice(&buffer[..bytes_read.min(room)]);
79 }
80 Err(error) if error.kind() == std::io::ErrorKind::Interrupted => continue,
81 Err(error) => return Err(error).context("failed to read PTY command output"),
82 }
83 }
84
85 if truncated {
86 collected.extend_from_slice(PTY_OUTPUT_TRUNCATED_MARKER);
87 }
88 Ok(collected)
89}
90
91use crate::audit::PermissionAuditLog;
92use crate::config::{CommandsConfig, PtyConfig};
93use crate::telemetry::perf;
94use crate::tools::exec_session::create_live_spool_file;
95use crate::tools::output_spooler::{SpoolIntegrity, SpoolLineCounter, encode_digest_hex};
96use crate::tools::path_env;
97use crate::tools::shell::resolve_fallback_shell;
98use crate::tools::types::VTCodePtySession;
99use crate::utils::gatekeeper;
100use crate::utils::path::ensure_path_within_workspace;
101use crate::utils::unicode_monitor::UNICODE_MONITOR;
102use vtcode_commons::canonicalize;
103
104mod session_ops;
105
106#[derive(Clone)]
107pub struct PtyManager {
108 workspace_root: PathBuf,
109 config: PtyConfig,
110 inner: Arc<PtyState>,
111 audit_log: Option<Arc<TokioMutex<PermissionAuditLog>>>,
112 extra_paths: Arc<RwLock<Arc<Vec<PathBuf>>>>,
113}
114
115#[derive(Default)]
116struct PtyState {
117 sessions: Mutex<HashMap<String, Arc<PtySessionHandle>>>,
118}
119
120impl PtyManager {
121 pub fn new(workspace_root: PathBuf, config: PtyConfig) -> Self {
122 let resolved_root = canonicalize(&workspace_root).unwrap_or_else(|_| workspace_root.clone());
123
124 let default_paths =
125 path_env::compute_extra_search_paths(&CommandsConfig::default().extra_path_entries, &resolved_root);
126
127 Self {
128 workspace_root: resolved_root,
129 config,
130 inner: Arc::new(PtyState::default()),
131 audit_log: None,
132 extra_paths: Arc::new(RwLock::new(Arc::new(default_paths))),
133 }
134 }
135
136 pub fn with_audit_log(mut self, audit_log: Arc<TokioMutex<PermissionAuditLog>>) -> Self {
137 self.audit_log = Some(audit_log);
138 self
139 }
140
141 pub fn config(&self) -> &PtyConfig {
142 &self.config
143 }
144
145 pub fn apply_commands_config(&self, commands_config: &CommandsConfig) {
146 let new_paths = path_env::compute_extra_search_paths(&commands_config.extra_path_entries, &self.workspace_root);
147 *self.extra_paths.write() = Arc::new(new_paths);
148 }
149
150 pub fn describe_working_dir(&self, path: &Path) -> String {
151 self.format_working_dir(path)
152 }
153
154 pub async fn run_command(&self, mut request: PtyCommandRequest) -> Result<PtyCommandResult> {
155 if request.command.is_empty() {
156 return Err(anyhow!("PTY command cannot be empty"));
157 }
158
159 let mut command = std::mem::take(&mut request.command);
160 let program = command.remove(0);
161 let args = command;
162 let timeout = clamp_timeout(request.timeout);
163 let work_dir = std::mem::take(&mut request.working_dir);
164 let size = request.size;
165 let start = Instant::now();
166
167 let mut tags = HashMap::new();
168 tags.insert("subsystem".to_string(), "pty".to_string());
169 tags.insert("program".to_string(), program.clone());
170 perf::record_value("vtcode.perf.spawn_count", 1.0, tags);
171
172 gatekeeper::check_quarantine_for_program(&program);
173 self.ensure_within_workspace(&work_dir)?;
174 let workspace_root = self.workspace_root.clone();
175 let extra_paths = self.extra_paths.read().clone();
176 let max_tokens = request.max_tokens;
177 let max_capture_bytes = self.config.max_scrollback_bytes;
178
179 let needs_lock = is_long_running_command(&program)
181 || (is_shell_program(&program) && args.iter().any(|arg| is_long_running_command_string(arg)));
182
183 let command_lock = if needs_lock {
188 Some(get_command_lock(&workspace_root))
189 } else {
190 None
191 };
192 let _command_guard = if let Some(ref lock) = command_lock {
193 debug!(
194 target: "vtcode.pty.command_lock",
195 program = %program,
196 workspace = %workspace_root.display(),
197 "Acquiring per-workspace command lock to serialize long-running invocations"
198 );
199 Some(lock.lock().await)
200 } else {
201 None
202 };
203
204 let result = tokio::task::spawn_blocking(move || -> Result<PtyCommandResult> {
205 let timeout_duration = Duration::from_millis(timeout);
206
207 let (exec_program, exec_args, display_program, _use_shell_wrapper) = if (is_shell_program(&program)
211 && args.iter().any(|arg| arg == "-c" || arg == "/C"))
212 || is_sandbox_wrapper_program(&program, &args)
213 {
214 (program.clone(), args.clone(), program.clone(), false)
216 } else {
217 let shell = resolve_fallback_shell();
218 let full_command = join(std::iter::once(program.clone()).chain(args.iter().cloned()));
219 (shell.clone(), vec!["-lc".to_owned(), full_command.clone()], program.clone(), true)
220 };
221
222 let mut builder = CommandBuilder::new(exec_program.clone());
223 for arg in &exec_args {
224 builder.arg(arg);
225 }
226 builder.cwd(&work_dir);
227 let extra_env = HashMap::new();
228 let sandbox_active = is_sandbox_wrapper_program(&program, &args);
229 set_command_environment_with_sandbox(
230 &mut builder,
231 &display_program,
232 size,
233 &workspace_root,
234 &extra_paths,
235 &extra_env,
236 &HashMap::new(),
237 sandbox_active,
238 );
239
240 #[cfg(unix)]
241 apply_process_owner(&mut builder, &exec_program, &exec_args)?;
242
243 let pty_system = native_pty_system();
244 let pair = pty_system.openpty(size).context("failed to allocate PTY pair")?;
245
246 let mut child = pair
247 .slave
248 .spawn_command(builder)
249 .with_context(|| format!("failed to spawn PTY command '{display_program}'"))?;
250 let child_pid = child.process_id();
251 let mut killer = child.clone_killer();
252 drop(pair.slave);
253
254 let reader = pair.master.try_clone_reader().context("failed to clone PTY reader")?;
255
256 let (wait_tx, wait_rx) = mpsc::channel();
257 let wait_thread = thread::spawn(move || {
258 let status = child.wait();
259 let _ = wait_tx.send(());
260 status
261 });
262
263 let reader_thread = thread::spawn(move || read_pty_output_capped(reader, max_capture_bytes));
264
265 let wait_result = match wait_rx.recv_timeout(timeout_duration) {
266 Ok(()) => wait_thread
267 .join()
268 .map_err(|panic| anyhow!("PTY command wait thread panicked: {panic:?}"))?,
269 Err(mpsc::RecvTimeoutError::Timeout) => {
270 if let Some(pid) = child_pid {
274 let _ = vtcode_bash_runner::graceful_kill_process_group_default(pid);
275 } else {
276 let _ = killer.kill();
277 }
278
279 let grace_period = Duration::from_millis(THREAD_JOIN_GRACE_PERIOD_MS);
281 match wait_rx.recv_timeout(grace_period) {
282 Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => {
283 }
286 Err(mpsc::RecvTimeoutError::Timeout) => {
287 warn!(
288 target: "vtcode.pty.timeout",
289 timeout_ms = timeout,
290 grace_ms = THREAD_JOIN_GRACE_PERIOD_MS,
291 "PTY command did not exit within grace period after kill, detaching threads"
292 );
293 drop(wait_thread);
295 drop(reader_thread);
296 return Err(anyhow!(
297 "PTY command timed out after {timeout} milliseconds and did not respond to kill signal"
298 ));
299 }
300 }
301
302 match wait_thread.join() {
304 Ok(result) => {
305 if let Err(error) = result {
306 warn!(
307 target: "vtcode.pty.timeout",
308 error = %error,
309 "PTY command wait error after timeout"
310 );
311 }
312 }
313 Err(panic) => {
314 warn!(
315 target: "vtcode.pty.timeout",
316 "PTY wait thread panicked: {:?}",
317 panic
318 );
319 }
320 }
321
322 let reader_handle = thread::spawn(move || reader_thread.join());
326 match reader_handle.join() {
327 Ok(Ok(Ok(_))) => {}
328 Ok(Ok(Err(e))) => {
329 warn!(
330 target: "vtcode.pty.timeout",
331 error = %e,
332 "PTY reader error after timeout"
333 );
334 }
335 Ok(Err(panic)) => {
336 warn!(
337 target: "vtcode.pty.timeout",
338 "PTY reader thread panicked: {:?}",
339 panic
340 );
341 }
342 Err(_) => {
343 warn!(
344 target: "vtcode.pty.timeout",
345 "Failed to join PTY reader thread wrapper"
346 );
347 }
348 }
349
350 return Err(anyhow!("PTY command timed out after {timeout} milliseconds"));
351 }
352 Err(mpsc::RecvTimeoutError::Disconnected) => {
353 let grace_period = Duration::from_millis(THREAD_JOIN_GRACE_PERIOD_MS);
356
357 let wait_wrapper = thread::spawn(move || wait_thread.join());
359 thread::sleep(grace_period);
360 if wait_wrapper.is_finished() {
361 match wait_wrapper.join() {
362 Ok(Ok(result)) => {
363 if let Err(error) = result {
364 return Err(error)
365 .context("failed to wait for PTY command after channel disconnect");
366 }
367 }
368 Ok(Err(panic)) => {
369 return Err(anyhow!("PTY wait thread panicked: {panic:?}"));
370 }
371 Err(_) => {
372 return Err(anyhow!("PTY wait channel disconnected and thread join failed"));
373 }
374 }
375 } else {
376 warn!(
377 target: "vtcode.pty.disconnect",
378 "PTY wait thread did not exit within grace period, detaching"
379 );
380 drop(reader_thread);
381 return Err(anyhow!("PTY command wait channel disconnected unexpectedly"));
382 }
383
384 match reader_thread.join() {
386 Ok(Ok(_)) => {}
387 Ok(Err(e)) => {
388 warn!(
389 target: "vtcode.pty.disconnect",
390 error = %e,
391 "PTY reader error after channel disconnect"
392 );
393 }
394 Err(panic) => {
395 warn!(
396 target: "vtcode.pty.disconnect",
397 "PTY reader panicked: {:?}",
398 panic
399 );
400 }
401 }
402
403 return Err(anyhow!("PTY command wait channel disconnected unexpectedly"));
404 }
405 };
406
407 let status = wait_result.context("failed to wait for PTY command to exit")?;
408
409 let output_bytes = reader_thread
410 .join()
411 .map_err(|panic| anyhow!("PTY command reader thread panicked: {panic:?}"))?
412 .context("failed to read PTY command output")?;
413 let mut output =
416 String::from_utf8(output_bytes).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned());
417 let exit_code = exit_status_code(status);
418
419 if let Some(max_tokens) = max_tokens {
421 if max_tokens > 0 {
422 if output.len() > max_tokens * 4 {
424 let truncate_point = (max_tokens * 4).min(output.len());
425 output.truncate(truncate_point);
426 output.push_str("\n[... truncated by max_tokens ...]");
427 }
428 } else {
429 }
431 }
432 Ok(PtyCommandResult {
435 exit_code,
436 output,
437 duration: start.elapsed(),
438 size,
439 applied_max_tokens: max_tokens,
440 })
441 })
442 .await
443 .context("failed to join PTY command task")??;
444
445 Ok(result)
446 }
447
448 pub(crate) fn working_dir_candidate(&self, requested: Option<&str>) -> Result<PathBuf> {
450 let requested = match requested {
451 Some(dir) if !dir.trim().is_empty() => dir.trim(),
452 _ => return Ok(self.workspace_root.clone()),
453 };
454
455 let candidate = self.workspace_root.join(requested);
456 ensure_path_within_workspace(&candidate, &self.workspace_root)
457 .map_err(|e| anyhow!("Working directory '{}' escapes the workspace root: {e}", candidate.display()))
458 }
459
460 pub async fn resolve_working_dir(&self, requested: Option<&str>) -> Result<PathBuf> {
461 let normalized = self.working_dir_candidate(requested)?;
462 if requested.is_none_or(|dir| dir.trim().is_empty()) {
463 return Ok(normalized);
464 }
465 let candidate = &normalized;
466 vtcode_commons::paths::ensure_path_within_workspace_resolved(&normalized, &self.workspace_root)
469 .await
470 .map_err(|e| anyhow!("Working directory '{}' escapes the workspace root: {e}", candidate.display()))?;
471 let metadata = tokio::fs::metadata(&normalized)
472 .await
473 .with_context(|| format!("Working directory '{}' does not exist", normalized.display()))?;
474 if !metadata.is_dir() {
475 return Err(anyhow!("Working directory '{}' is not a directory", normalized.display()));
476 }
477 Ok(normalized)
478 }
479
480 pub fn create_session(
481 &self,
482 session_id: String,
483 command: Vec<String>,
484 working_dir: PathBuf,
485 size: PtySize,
486 ) -> Result<VTCodePtySession> {
487 self.create_session_with_bridge(session_id, command, working_dir, size, HashMap::new(), None)
488 }
489
490 fn create_session_with_bridge(
491 &self,
492 session_id: String,
493 command: Vec<String>,
494 working_dir: PathBuf,
495 size: PtySize,
496 extra_env: HashMap<String, String>,
497 zsh_exec_bridge: Option<crate::zsh_exec_bridge::ZshExecBridgeSession>,
498 ) -> Result<VTCodePtySession> {
499 self.create_session_with_bridge_sandboxed(
500 session_id,
501 command,
502 working_dir,
503 size,
504 extra_env,
505 zsh_exec_bridge,
506 HashMap::new(),
507 false,
508 )
509 }
510
511 pub(crate) fn create_session_with_bridge_sandboxed(
512 &self,
513 session_id: String,
514 command: Vec<String>,
515 working_dir: PathBuf,
516 size: PtySize,
517 extra_env: HashMap<String, String>,
518 zsh_exec_bridge: Option<crate::zsh_exec_bridge::ZshExecBridgeSession>,
519 trusted_env: HashMap<String, String>,
520 sandbox_active: bool,
521 ) -> Result<VTCodePtySession> {
522 if command.is_empty() {
523 return Err(anyhow!(
524 "PTY session command cannot be empty.\n\
525 This is an internal error - command validation should have caught this earlier.\n\
526 Please report this with the command-session parameters used."
527 ));
528 }
529
530 let mut sessions = self.inner.sessions.lock();
532 use hashbrown::hash_map::Entry;
533 let entry = match sessions.entry(session_id.clone()) {
534 Entry::Occupied(_) => {
535 return Err(anyhow!("PTY session '{session_id}' already exists"));
536 }
537 Entry::Vacant(e) => e,
538 };
539
540 let mut command_parts = command;
541 let program = command_parts.remove(0);
542 let args = command_parts;
543 let extra_paths = self.extra_paths.read().clone();
544
545 let (exec_program, exec_args, display_program) = if (is_shell_program(&program)
549 && args.iter().any(|arg| arg == "-c" || arg == "/C"))
550 || is_sandbox_wrapper_program(&program, &args)
551 {
552 (program.clone(), args.clone(), program.clone())
554 } else {
555 let shell = resolve_fallback_shell();
556 let full_command = join(std::iter::once(program.clone()).chain(args.iter().cloned()));
557
558 if full_command.is_empty() {
560 return Err(anyhow!("Failed to construct command string from program '{program}' and args {args:?}"));
561 }
562
563 (shell.clone(), vec!["-lc".to_owned(), full_command.clone()], program.clone())
564 };
565
566 let pty_system = native_pty_system();
567 let pair = pty_system.openpty(size).context("failed to allocate PTY pair")?;
568
569 let mut builder = CommandBuilder::new(exec_program.clone());
570 for arg in &exec_args {
571 builder.arg(arg);
572 }
573 builder.cwd(&working_dir);
574 self.ensure_within_workspace(&working_dir)?;
575 set_command_environment_with_sandbox(
576 &mut builder,
577 &display_program,
578 size,
579 &self.workspace_root,
580 &extra_paths,
581 &extra_env,
582 &trusted_env,
583 sandbox_active,
584 );
585
586 #[cfg(unix)]
587 apply_process_owner(&mut builder, &exec_program, &exec_args)?;
588
589 let child = pair
590 .slave
591 .spawn_command(builder)
592 .with_context(|| format!("failed to spawn PTY session command '{display_program}'"))?;
593
594 let child_pid = child.process_id();
596
597 drop(pair.slave);
598
599 let master = pair.master;
600 let mut reader = master.try_clone_reader().context("failed to clone PTY reader")?;
601 let writer = master.take_writer().context("failed to take PTY writer")?;
602
603 let screen_state = Arc::new(Mutex::new(PtyScreenState::new(size, self.config.scrollback_lines)));
604 let scrollback =
605 Arc::new(Mutex::new(PtyScrollback::new(self.config.scrollback_lines, self.config.max_scrollback_bytes)));
606
607 let (output_spool_relative_path, output_spool_file) =
608 create_live_spool_file(&self.workspace_root, &sanitize_session_id(&session_id));
609 let output_spool_path = output_spool_relative_path.to_string_lossy().replace('\\', "/");
610 let (output_spool_tx, mut output_spool_rx) = tokio_mpsc::channel::<Vec<u8>>(64);
611 let output_total_bytes = Arc::new(AtomicU64::new(0));
612 let output_total_bytes_for_reader = Arc::clone(&output_total_bytes);
613 let output_spool_failed = Arc::new(AtomicBool::new(false));
614 let output_spool_failed_for_task = Arc::clone(&output_spool_failed);
615 let output_spool_ready = Arc::new(AtomicBool::new(false));
616 let output_spool_ready_for_task = Arc::clone(&output_spool_ready);
617 let output_spool_finished = Arc::new(AtomicBool::new(false));
618 let output_spool_finished_for_task = Arc::clone(&output_spool_finished);
619 let output_spool_integrity = Arc::new(Mutex::new(None));
620 let output_spool_integrity_for_task = Arc::clone(&output_spool_integrity);
621
622 let output_spool_task = async move {
623 let mut spool_file = output_spool_file.map(tokio::fs::File::from_std);
627
628 if spool_file.is_none() {
629 output_spool_failed_for_task.store(true, Ordering::Release);
630 } else {
631 output_spool_ready_for_task.store(true, Ordering::Release);
632 }
633 let mut spool_redactor = vtcode_commons::sanitizer::StreamingSecretRedactor::default();
634 let mut spool_hasher = Sha256::new();
635 let mut spool_byte_count = 0_u64;
636 let mut spool_lines = SpoolLineCounter::default();
637
638 while let Some(chunk) = output_spool_rx.recv().await {
639 let Some(file) = spool_file.as_mut() else {
640 continue;
641 };
642 let text = String::from_utf8_lossy(&chunk);
643 let sanitized = spool_redactor.push(&text);
644 if !sanitized.is_empty() {
645 if file.write_all(sanitized.as_bytes()).await.is_err() {
646 output_spool_failed_for_task.store(true, Ordering::Release);
647 spool_file = None;
648 } else {
649 spool_hasher.update(sanitized.as_bytes());
650 spool_lines.append(sanitized.as_bytes());
651 spool_byte_count = spool_byte_count.saturating_add(sanitized.len() as u64);
652 }
653 }
654 }
655
656 if let Some(file) = spool_file.as_mut() {
657 let sanitized = spool_redactor.finish();
658 let write_failed = !sanitized.is_empty() && file.write_all(sanitized.as_bytes()).await.is_err();
659 if !write_failed && !sanitized.is_empty() {
660 spool_hasher.update(sanitized.as_bytes());
661 spool_lines.append(sanitized.as_bytes());
662 spool_byte_count = spool_byte_count.saturating_add(sanitized.len() as u64);
663 }
664 if write_failed || file.sync_all().await.is_err() {
668 output_spool_failed_for_task.store(true, Ordering::Release);
669 } else {
670 *output_spool_integrity_for_task.lock() = Some(SpoolIntegrity {
671 byte_count: spool_byte_count,
672 line_count: spool_lines.line_count(),
673 sha256: encode_digest_hex(spool_hasher.finalize()),
674 });
675 }
676 }
677 output_spool_finished_for_task.store(true, Ordering::Release);
678 };
679
680 if let Ok(handle) = tokio::runtime::Handle::try_current() {
681 handle.spawn(output_spool_task);
682 } else {
683 output_spool_failed.store(true, Ordering::Release);
684 output_spool_finished.store(true, Ordering::Release);
685 }
686
687 debug!(
688 session_id = %session_id,
689 rows = size.rows,
690 cols = size.cols,
691 "Created PTY session"
692 );
693 let screen_state_clone = Arc::clone(&screen_state);
694 let scrollback_clone = Arc::clone(&scrollback);
695 let reader_completed = Arc::new(AtomicBool::new(false));
696 let reader_completed_clone = Arc::clone(&reader_completed);
697 let session_name = session_id.clone();
698 let output_spool_failed_for_reader = Arc::clone(&output_spool_failed);
699 let mut output_spool_tx_for_reader = Some(output_spool_tx);
700 UNICODE_MONITOR.start_session();
702
703 let reader_thread = thread::Builder::new()
704 .name(format!("vtcode-pty-reader-{session_name}"))
705 .spawn(move || {
706 let mut buffer = [0u8; 8192]; let mut utf8_buffer: Vec<u8> = Vec::with_capacity(8192); let mut total_bytes = 0usize;
709 let mut unicode_detection_hits = 0usize;
710
711 loop {
712 match reader.read(&mut buffer) {
713Ok(0) => {
714 if !utf8_buffer.is_empty() {
715 let mut scrollback = scrollback_clone.lock();
716 scrollback.push_utf8(&mut utf8_buffer, true);
717 }
718 debug!("PTY session '{}' reader reached EOF (processed {} bytes, {} unicode detections)",
719 session_name, total_bytes, unicode_detection_hits);
720 break;
721}
722 Ok(bytes_read) => {
723 let chunk = &buffer[..bytes_read];
724 total_bytes += bytes_read;
725 output_total_bytes_for_reader.fetch_add(bytes_read as u64, Ordering::Relaxed);
726 let send_result = output_spool_tx_for_reader
727 .as_ref()
728 .map(|sender| sender.blocking_send(chunk.to_vec()));
729 if matches!(send_result, Some(Err(_))) {
730 output_spool_failed_for_reader.store(true, Ordering::Release);
731 warn!(
732 "PTY session '{}' spool receiver closed after {} bytes; retaining screen and scrollback output",
733 session_name, total_bytes
734 );
735 output_spool_tx_for_reader = None;
736 }
737
738 let likely_unicode = chunk.iter().any(|&b| b >= 0x80);
740 if likely_unicode {
741 unicode_detection_hits += 1;
742 }
743
744 {
745 let mut screen_state = screen_state_clone.lock();
746 screen_state.process(chunk);
747 }
748
749 utf8_buffer.extend_from_slice(chunk);
750 {
751 let mut scrollback = scrollback_clone.lock();
752 scrollback.push_utf8(&mut utf8_buffer, false);
753 }
754
755 if utf8_buffer.capacity() > 32768 && utf8_buffer.len() < 1024 {
757 utf8_buffer.shrink_to_fit();
758 }
759}
760Err(error) => {
761 warn!("PTY session '{}' reader error: {} (processed {} bytes)",
762 session_name, error, total_bytes);
763 break;
764}
765 }
766 }
767 debug!("PTY session '{}' reader thread finished (total: {} bytes, unicode detections: {})",
768 session_name, total_bytes, unicode_detection_hits);
769
770 reader_completed_clone.store(true, Ordering::Release);
775
776 UNICODE_MONITOR.end_session();
778
779 if unicode_detection_hits > 0 {
781 let scrollback = scrollback_clone.lock();
782 let metrics = scrollback.metrics();
783 if metrics.unicode_errors > 0 {
784warn!("PTY session '{}' had {} unicode errors during processing",
785 session_name, metrics.unicode_errors);
786 }
787 if metrics.total_unicode_chars > 0 {
788info!("PTY session '{}' processed {} unicode characters across {} sessions with {} buffer remainder",
789 session_name, metrics.total_unicode_chars, metrics.unicode_sessions, metrics.utf8_buffer_size);
790 }
791 }
792 })
793 .context("failed to spawn PTY reader thread")?;
794
795 let metadata = VTCodePtySession {
796 id: session_id.clone(),
797 command: program,
798 args,
799 working_dir: Some(self.format_working_dir(&working_dir)),
800 rows: size.rows,
801 cols: size.cols,
802 child_pid,
803 started_at: Some(Utc::now()),
804 lifecycle_state: Some(crate::tools::types::VTCodeSessionLifecycleState::Running),
805 exit_code: None,
806 screen_contents: None,
807 scrollback: None,
808 };
809
810 entry.insert(Arc::new(PtySessionHandle {
812 master: Mutex::new(master),
813 child: Mutex::new(child),
814 child_pid,
815 writer: Mutex::new(Some(writer)),
816 screen_state,
817 scrollback,
818 reader_thread: Mutex::new(Some(reader_thread)),
819 reader_completed,
820 metadata: metadata.clone(),
821 last_input: Mutex::new(None),
822 _zsh_exec_bridge: zsh_exec_bridge,
823 output_total_bytes,
824 output_spool_failed,
825 output_spool_ready,
826 output_spool_finished,
827 output_spool_path,
828 output_spool_integrity,
829 }));
830
831 Ok(metadata)
832 }
833
834 fn format_working_dir(&self, path: &Path) -> String {
835 match path.strip_prefix(&self.workspace_root) {
836 Ok(relative) if relative.as_os_str().is_empty() => ".".into(),
837 Ok(relative) => relative.to_string_lossy().replace("\\", "/"),
838 Err(_) => path.to_string_lossy().into_owned(),
839 }
840 }
841
842 fn ensure_within_workspace(&self, candidate: &Path) -> Result<()> {
843 ensure_path_within_workspace(candidate, &self.workspace_root).map(|_| ())
844 }
845}
846
847#[cfg(unix)]
850fn apply_process_owner(builder: &mut CommandBuilder, program: &str, args: &[String]) -> Result<()> {
851 use vtcode_bash_runner::process_owner::{OWNER_SOCKET_ENV, OwnedCommand};
852 if let Some(owned) = OwnedCommand::from_environment(program.as_ref(), args, None)? {
853 let argv = builder.get_argv_mut();
854 argv.clear();
855 argv.push(owned.executable.into_os_string());
856 argv.extend(owned.arguments);
857 builder.env(OWNER_SOCKET_ENV, owned.owner_socket);
858 }
859 Ok(())
860}
861
862#[cfg(test)]
863mod tests;