Skip to main content

vtcode_core/tools/pty/
manager.rs

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
36/// Per-workspace command locks to serialize long-running toolchain commands.
37/// Keyed by canonicalized workspace path to prevent lockfile contention.
38/// This is more granular than a global lock - different workspaces can run concurrently.
39///
40/// Entries are held weakly: once the last `Arc` returned by `get_command_lock` is
41/// dropped (the command that held it completes), the entry is collected on the
42/// next lookup, so the map stays bounded by the number of concurrently-held
43/// locks rather than the number of workspaces ever seen.
44static WORKSPACE_COMMAND_LOCKS: Lazy<Mutex<HashMap<PathBuf, Weak<tokio::sync::Mutex<()>>>>> =
45    Lazy::new(|| Mutex::new(HashMap::new()));
46
47/// Get or create a command lock for the given workspace root
48fn 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
59/// Grace period to wait for threads to exit after killing the process (ms)
60const THREAD_JOIN_GRACE_PERIOD_MS: u64 = 500;
61
62const PTY_OUTPUT_TRUNCATED_MARKER: &[u8] = b"\n[... output truncated: capture limit reached ...]";
63
64/// Reads to EOF but retains at most `max_bytes`; the surplus is still drained so the child never blocks on a full PTY.
65fn 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        // Determine if this command needs serialization to avoid contention
180        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        // Acquire per-workspace lock if needed to prevent lockfile contention.
184        // This prevents "blocking waiting for file lock" errors when the agent
185        // triggers multiple long-running commands before previous ones complete.
186        // Using per-workspace lock allows concurrent commands in different workspaces.
187        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            // Use login shell for command execution to ensure user's PATH and environment
208            // is properly initialized from their shell configuration files (~/.bashrc, ~/.zshrc, etc).
209            // However, we avoid double-wrapping if the command is already a shell invocation.
210            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                // Already a shell command or sandbox wrapper, don't wrap again.
215                (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                    // Kill the process group and the direct child process handle.
271                    // vtcode_bash_runner::graceful_kill_process_group_default now handles
272                    // the robust 'more kills' pattern which ensures descendants do not survive.
273                    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                    // Wait with a grace period - don't hang forever
280                    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                            // Process exited, try to join threads with timeout
284                            // If they don't exit within grace period, detach them
285                        }
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                            // Detach threads by dropping handles - they may leak but we don't hang
294                            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                    // Try to join wait thread (should be quick since process exited)
303                    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                    // Try to join reader thread (may take a moment for PTY to close)
323                    // Use a thread-local timeout via a parking_lot based approach
324                    // For simplicity, just drop the handle if it doesn't complete quickly
325                    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                    // Channel disconnected - process likely crashed
354                    // Try to join with grace period
355                    let grace_period = Duration::from_millis(THREAD_JOIN_GRACE_PERIOD_MS);
356
357                    // Spawn wrapper thread to allow timeout on join
358                    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                    // Also try to get reader output
385                    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            // Fast zero-copy path for valid UTF-8 (the common case); only
414            // allocate a lossy copy when the bytes are actually invalid.
415            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            // Apply max_tokens truncation if specified
420            if let Some(max_tokens) = max_tokens {
421                if max_tokens > 0 {
422                    // Simple byte-based truncation
423                    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                    // Keep original if max_tokens is not valid
430                }
431            }
432            // Keep original if max_tokens is None
433
434            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    /// Lexical cwd shared by launch validation and recovery target lookup.
449    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        // Symlink-aware containment: a symlinked directory inside the
467        // workspace must not move the command cwd outside it.
468        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        // Use entry API to avoid double lookup
531        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        // Use login shell for command execution to ensure user's PATH and environment
546        // is properly initialized from their shell configuration files (~/.bashrc, ~/.zshrc, etc).
547        // However, we avoid double-wrapping if the command is already a shell invocation.
548        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            // Already a shell command, don't wrap again
553            (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            // Verify we have a valid command string
559            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        // Capture the child process ID for process group management
595        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            // Spool files are live-read while the session runs, so every
624            // chunk must reach disk immediately; buffering would hide output
625            // from concurrent readers until `flush`.
626            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                // Spool files are live-read while the session runs, so every
665                // chunk must reach disk immediately; buffering would hide
666                // output from concurrent readers until `flush`.
667                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        // Start unicode monitoring for this session
701        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]; // Increased buffer size for better performance
707                let mut utf8_buffer: Vec<u8> = Vec::with_capacity(8192); // Pre-allocate buffer
708                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    // Quick unicode detection heuristic
739    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    // Periodic buffer cleanup to prevent excessive memory usage
756    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                // Signal that the reader thread has finished pushing all data.
771                // This must be set BEFORE any further scrollback lock acquisitions
772                // so that is_output_drained() sees both pending==false and completed==true
773                // atomically under the scrollback lock.
774                reader_completed_clone.store(true, Ordering::Release);
775
776                // End unicode monitoring for this session
777                UNICODE_MONITOR.end_session();
778
779                // Log unicode statistics if any unicode was detected
780                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        // Use the entry we obtained earlier to insert without additional lookup
811        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/// Apply lifetime ownership after environment/sandbox selection, retaining the
848/// builder's PTY, cwd, and environment policy and the admitted command's argv.
849#[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;