wraith/src-tauri/src/pty/mod.rs
Vantz Stockwell d98600a319
Some checks failed
Build & Sign Wraith / Build Windows + Sign (push) Failing after 3m39s
fix: MCP bridge built, signed, and shipped in CI releases
- Removed useless CLAUDE_MCP_SERVERS env var injection (doesn't work)
- CI builds wraith-mcp-bridge.exe as a separate cargo --bin step
- Bridge binary signed with EV cert alongside the installer
- Uploaded to Gitea packages per version
- Attached to Gitea release as a downloadable asset
- Users add to PATH then: claude mcp add wraith -- wraith-mcp-bridge

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 12:55:17 -04:00

232 lines
8.0 KiB
Rust

//! Local PTY service — spawns shells for the AI copilot panel.
use std::io::{Read, Write};
use std::sync::{Arc, Mutex};
use base64::Engine;
use dashmap::DashMap;
use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize};
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use crate::mcp::ScrollbackRegistry;
#[derive(Debug, Serialize, Clone)]
pub struct ShellInfo {
pub name: String,
pub path: String,
}
pub struct PtySession {
pub id: String,
pub shell_path: String,
writer: Mutex<Box<dyn Write + Send>>,
master: Mutex<Box<dyn MasterPty + Send>>,
child: Mutex<Box<dyn Child + Send + Sync>>,
}
pub struct PtyService {
sessions: DashMap<String, Arc<PtySession>>,
}
impl PtyService {
pub fn new() -> Self {
Self { sessions: DashMap::new() }
}
/// Detect available shells on the system.
pub fn list_shells(&self) -> Vec<ShellInfo> {
let mut shells = Vec::new();
#[cfg(unix)]
{
if let Ok(user_shell) = std::env::var("SHELL") {
if std::path::Path::new(&user_shell).exists() {
let name = std::path::Path::new(&user_shell)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("shell")
.to_string();
shells.push(ShellInfo { name, path: user_shell });
}
}
for (name, path) in [("bash", "/bin/bash"), ("zsh", "/bin/zsh"), ("sh", "/bin/sh")] {
if std::path::Path::new(path).exists() && !shells.iter().any(|s| s.path == path) {
shells.push(ShellInfo { name: name.to_string(), path: path.to_string() });
}
}
}
#[cfg(windows)]
{
shells.push(ShellInfo { name: "PowerShell".to_string(), path: "powershell.exe".to_string() });
shells.push(ShellInfo { name: "CMD".to_string(), path: "cmd.exe".to_string() });
for git_bash in [
r"C:\Program Files\Git\bin\bash.exe",
r"C:\Program Files (x86)\Git\bin\bash.exe",
] {
if std::path::Path::new(git_bash).exists() {
shells.push(ShellInfo { name: "Git Bash".to_string(), path: git_bash.to_string() });
break;
}
}
// WSL (Windows Subsystem for Linux)
if std::path::Path::new(r"C:\Windows\System32\wsl.exe").exists() {
shells.push(ShellInfo { name: "WSL".to_string(), path: r"C:\Windows\System32\wsl.exe".to_string() });
}
}
shells
}
/// Spawn a local shell and start reading its output.
pub fn spawn(
&self,
shell_path: &str,
cols: u16,
rows: u16,
app_handle: AppHandle,
scrollback: &ScrollbackRegistry,
) -> Result<String, String> {
let session_id = uuid::Uuid::new_v4().to_string();
wraith_log!("[PTY] Spawning shell: {} (session {})", shell_path, session_id);
let pty_system = native_pty_system();
let pair = pty_system
.openpty(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("Failed to open PTY: {}", e))?;
let cmd = CommandBuilder::new(shell_path);
let child = pair.slave
.spawn_command(cmd)
.map_err(|e| format!("Failed to spawn shell '{}': {}", shell_path, e))?;
let reader = pair.master
.try_clone_reader()
.map_err(|e| format!("Failed to clone PTY reader: {}", e))?;
let writer = pair.master
.take_writer()
.map_err(|e| format!("Failed to take PTY writer: {}", e))?;
let session = Arc::new(PtySession {
id: session_id.clone(),
shell_path: shell_path.to_string(),
writer: Mutex::new(writer),
master: Mutex::new(pair.master),
child: Mutex::new(child),
});
self.sessions.insert(session_id.clone(), session);
// Create scrollback buffer for MCP terminal_read
let scrollback_buf = scrollback.create(&session_id);
// Output reader loop — runs in a dedicated OS thread because
// portable-pty's reader is synchronous (std::io::Read) and
// long-lived. Using std::thread::spawn avoids requiring a
// tokio runtime context (sync Tauri commands may not have one).
let sid = session_id.clone();
let app = app_handle;
std::thread::spawn(move || {
let mut reader = std::io::BufReader::new(reader);
let mut buf = [0u8; 4096];
loop {
match reader.read(&mut buf) {
Ok(0) => {
let _ = app.emit(&format!("pty:close:{}", sid), ());
break;
}
Ok(n) => {
scrollback_buf.push(&buf[..n]);
let encoded = base64::engine::general_purpose::STANDARD.encode(&buf[..n]);
let _ = app.emit(&format!("pty:data:{}", sid), encoded);
}
Err(_) => {
let _ = app.emit(&format!("pty:close:{}", sid), ());
break;
}
}
}
});
Ok(session_id)
}
/// Write data to a PTY session's stdin.
pub fn write(&self, session_id: &str, data: &[u8]) -> Result<(), String> {
let session = self.sessions.get(session_id)
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
let mut writer = session.writer.lock()
.map_err(|e| format!("Failed to lock PTY writer: {}", e))?;
writer.write_all(data)
.map_err(|e| format!("Failed to write to PTY {}: {}", session_id, e))
}
/// Resize a PTY session.
pub fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
let session = self.sessions.get(session_id)
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
let master = session.master.lock()
.map_err(|e| format!("Failed to lock PTY master: {}", e))?;
master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 })
.map_err(|e| format!("Failed to resize PTY {}: {}", session_id, e))
}
/// Kill and remove a PTY session.
pub fn disconnect(&self, session_id: &str) -> Result<(), String> {
let (_, session) = self.sessions.remove(session_id)
.ok_or_else(|| format!("PTY session {} not found", session_id))?;
if let Ok(mut child) = session.child.lock() {
let _ = child.kill();
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn list_shells_returns_at_least_one() {
let svc = PtyService::new();
let shells = svc.list_shells();
assert!(!shells.is_empty(), "should find at least one shell");
for shell in &shells {
assert!(!shell.name.is_empty());
assert!(!shell.path.is_empty());
}
}
#[test]
fn list_shells_no_duplicates() {
let svc = PtyService::new();
let shells = svc.list_shells();
let mut paths: Vec<&str> = shells.iter().map(|s| s.path.as_str()).collect();
let original_len = paths.len();
paths.sort();
paths.dedup();
assert_eq!(original_len, paths.len(), "shell list should not contain duplicates");
}
#[test]
fn disconnect_nonexistent_session_errors() {
let svc = PtyService::new();
assert!(svc.disconnect("nonexistent").is_err());
}
#[test]
fn write_nonexistent_session_errors() {
let svc = PtyService::new();
assert!(svc.write("nonexistent", b"hello").is_err());
}
#[test]
fn resize_nonexistent_session_errors() {
let svc = PtyService::new();
assert!(svc.resize("nonexistent", 80, 24).is_err());
}
}