use std::collections::HashMap;
use std::fs;
use std::io::{Read, Write};
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::{Arc, Mutex};
use std::thread;
use base64::Engine;
use portable_pty::{CommandBuilder, NativePtySystem, PtySize, PtySystem};
use serde_json::Value;
use crate::events::EventEmitter;
use crate::sandbox::{is_dir_allowed, safe_lock, validate_path, SharedList};
const MAX_PTY_WRITE: usize = 1024 * 1024;
pub struct PtyConfig {
pub allowed_tools: Vec<String>,
}
pub struct PtySession {
writer: Box<dyn Write + Send>,
master: Box<dyn portable_pty::MasterPty + Send>,
child: Box<dyn portable_pty::Child + Send + Sync>,
}
pub struct PtySessions {
pub inner: Arc<Mutex<HashMap<u32, PtySession>>>,
pub next_id: AtomicU32,
pub config: PtyConfig,
}
impl PtySessions {
pub fn new(config: PtyConfig) -> Self {
Self {
inner: Arc::new(Mutex::new(HashMap::new())),
next_id: AtomicU32::new(1),
config,
}
}
}
fn validate_pty_tool<'a>(sessions: &'a PtySessions, tool: &str) -> Result<&'a str, String> {
sessions
.config
.allowed_tools
.iter()
.find(|t| t.as_str() == tool)
.map(|s| s.as_str())
.ok_or_else(|| format!("Tool not allowed: {}", tool))
}
fn trusted_dirs() -> Vec<PathBuf> {
let mut dirs: Vec<PathBuf> = vec![
"/opt/homebrew/bin".into(),
"/usr/local/bin".into(),
"/usr/bin".into(),
"/bin".into(),
];
if let Ok(home) = std::env::var("HOME") {
let home = PathBuf::from(home);
dirs.push(home.join(".cargo/bin"));
dirs.push(home.join(".local/bin"));
dirs.push(home.join(".volta/bin"));
dirs.push(home.join(".npm-global/bin"));
dirs.push(home.join(".bun/bin"));
}
dirs
}
fn resolve_pty_tool(tool: &str) -> Result<String, String> {
for d in &trusted_dirs() {
let candidate = d.join(tool);
if candidate.is_file() {
return Ok(candidate.to_string_lossy().to_string());
}
}
if let Ok(home) = std::env::var("HOME") {
let nvm = PathBuf::from(home).join(".nvm/versions/node");
if let Ok(entries) = fs::read_dir(&nvm) {
for e in entries.flatten() {
let p = e.path().join("bin").join(tool);
if p.is_file() {
return Ok(p.to_string_lossy().to_string());
}
}
}
}
Err(format!("Tool `{}` not found in trusted install dirs", tool))
}
pub fn spawn(
sessions: &PtySessions,
allowed_dirs: &SharedList,
emitter: &EventEmitter,
args: &Value,
) -> Result<Value, String> {
let tool = args
.get("tool")
.and_then(|v| v.as_str())
.ok_or("missing tool")?;
let cwd = args
.get("cwd")
.and_then(|v| v.as_str())
.ok_or("missing cwd")?;
let cols = args.get("cols").and_then(|v| v.as_u64()).unwrap_or(80) as u16;
let rows = args.get("rows").and_then(|v| v.as_u64()).unwrap_or(24) as u16;
let tool = validate_pty_tool(sessions, tool)?.to_string();
validate_path(cwd)?;
let canonical_cwd = is_dir_allowed(cwd, allowed_dirs)?;
let pty_system = NativePtySystem::default();
let pair = pty_system
.openpty(PtySize {
rows: rows.max(1),
cols: cols.max(1),
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Cannot open pty: {}", e))?;
let tool_abs = resolve_pty_tool(&tool)?;
let mut cmd = CommandBuilder::new(&tool_abs);
cmd.cwd(&canonical_cwd);
cmd.env("TERM", "xterm-256color");
let mut safe_path = String::from("/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin");
if let Ok(home) = std::env::var("HOME") {
safe_path.push_str(&format!(
":{h}/.cargo/bin:{h}/.local/bin:{h}/.volta/bin:{h}/.npm-global/bin:{h}/.bun/bin",
h = home
));
cmd.env("HOME", &home);
}
cmd.env("PATH", safe_path);
let child = pair
.slave
.spawn_command(cmd)
.map_err(|e| format!("Cannot spawn {}: {}", tool, e))?;
let mut reader = pair
.master
.try_clone_reader()
.map_err(|e| format!("Cannot clone pty reader: {}", e))?;
let writer = pair
.master
.take_writer()
.map_err(|e| format!("Cannot take pty writer: {}", e))?;
let id = sessions.next_id.fetch_add(1, Ordering::SeqCst);
{
let mut map = safe_lock(&sessions.inner);
map.insert(
id,
PtySession {
writer,
master: pair.master,
child,
},
);
}
let emitter_reader = emitter.clone();
let sessions_for_reader = Arc::clone(&sessions.inner);
let _ = thread::Builder::new()
.name(format!("pty-reader-{}", id))
.spawn(move || {
let mut buf = [0u8; 8192];
loop {
match reader.read(&mut buf) {
Ok(0) => break,
Ok(n) => {
let encoded = base64::engine::general_purpose::STANDARD.encode(&buf[..n]);
emitter_reader
.emit("pty:data", serde_json::json!({ "id": id, "data": encoded }));
}
Err(_) => break,
}
}
emitter_reader.emit("pty:exit", serde_json::json!({ "id": id }));
let mut map = safe_lock(&sessions_for_reader);
map.remove(&id);
});
Ok(Value::from(id))
}
pub fn write(sessions: &PtySessions, args: &Value) -> Result<Value, String> {
let id = args
.get("id")
.and_then(|v| v.as_u64())
.ok_or("missing id")? as u32;
let data = args
.get("data")
.and_then(|v| v.as_str())
.ok_or("missing data")?;
if data.len() > MAX_PTY_WRITE {
return Err("Input too large".to_string());
}
let mut map = safe_lock(&sessions.inner);
let session = map.get_mut(&id).ok_or("Session not found")?;
session
.writer
.write_all(data.as_bytes())
.map_err(|e| format!("Write failed: {}", e))?;
Ok(Value::Null)
}
pub fn resize(sessions: &PtySessions, args: &Value) -> Result<Value, String> {
let id = args
.get("id")
.and_then(|v| v.as_u64())
.ok_or("missing id")? as u32;
let cols = args.get("cols").and_then(|v| v.as_u64()).unwrap_or(80) as u16;
let rows = args.get("rows").and_then(|v| v.as_u64()).unwrap_or(24) as u16;
let map = safe_lock(&sessions.inner);
let session = map.get(&id).ok_or("Session not found")?;
session
.master
.resize(PtySize {
rows: rows.max(1),
cols: cols.max(1),
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| format!("Resize failed: {}", e))?;
Ok(Value::Null)
}
pub fn kill(sessions: &PtySessions, args: &Value) -> Result<Value, String> {
let id = args
.get("id")
.and_then(|v| v.as_u64())
.ok_or("missing id")? as u32;
let mut map = safe_lock(&sessions.inner);
if let Some(mut session) = map.remove(&id) {
let _ = session.child.kill();
}
Ok(Value::Null)
}
#[cfg(test)]
mod tests {
use super::*;
fn test_sessions() -> PtySessions {
PtySessions::new(PtyConfig {
allowed_tools: vec!["claude".into(), "codex".into()],
})
}
#[test]
fn allows_configured_tools() {
let s = test_sessions();
assert_eq!(validate_pty_tool(&s, "claude").unwrap(), "claude");
assert_eq!(validate_pty_tool(&s, "codex").unwrap(), "codex");
}
#[test]
fn rejects_unlisted_binaries() {
let s = test_sessions();
assert!(validate_pty_tool(&s, "sh").is_err());
assert!(validate_pty_tool(&s, "bash").is_err());
assert!(validate_pty_tool(&s, "claude; rm -rf /").is_err());
assert!(validate_pty_tool(&s, "/usr/bin/claude").is_err());
assert!(validate_pty_tool(&s, "").is_err());
}
#[test]
fn rejects_path_like_tool_names() {
let s = test_sessions();
assert!(validate_pty_tool(&s, "./claude").is_err());
assert!(validate_pty_tool(&s, "../claude").is_err());
}
#[test]
fn trusted_dirs_include_standard_unix_bins() {
let dirs = trusted_dirs();
let as_str: Vec<String> = dirs.iter().map(|p| p.to_string_lossy().into()).collect();
assert!(as_str.iter().any(|d| d == "/usr/bin"));
assert!(as_str.iter().any(|d| d == "/bin"));
assert!(as_str.iter().any(|d| d == "/opt/homebrew/bin"));
assert!(as_str.iter().any(|d| d == "/usr/local/bin"));
}
#[test]
fn trusted_dirs_include_home_managers_when_home_set() {
if std::env::var("HOME").is_err() {
return;
}
let dirs = trusted_dirs();
let as_str: Vec<String> = dirs.iter().map(|p| p.to_string_lossy().into()).collect();
assert!(as_str.iter().any(|d| d.ends_with("/.cargo/bin")));
assert!(as_str.iter().any(|d| d.ends_with("/.local/bin")));
assert!(as_str.iter().any(|d| d.ends_with("/.bun/bin")));
}
#[test]
fn resolve_pty_tool_errors_for_nonexistent() {
let err = resolve_pty_tool("definitely-not-installed-anywhere-xyz").unwrap_err();
assert!(err.contains("not found"), "got: {err}");
}
#[test]
fn write_rejects_oversize_payload() {
let s = test_sessions();
let big = "a".repeat(MAX_PTY_WRITE + 1);
let err = write(&s, &serde_json::json!({ "id": 1u32, "data": big })).unwrap_err();
assert!(err.contains("too large"), "got: {err}");
}
#[test]
fn spawn_rejects_unknown_tool() {
let s = test_sessions();
let allowed_dirs = crate::sandbox::new_list();
let (tx, _rx) = std::sync::mpsc::channel::<()>();
drop(tx);
assert!(validate_pty_tool(&s, "rogue").is_err());
let v = serde_json::json!({ "tool": "rogue" });
let _ = (s, allowed_dirs, v); }
#[test]
fn resize_and_kill_on_missing_session() {
let s = test_sessions();
assert!(resize(
&s,
&serde_json::json!({ "id": 999u32, "cols": 80, "rows": 24 })
)
.is_err());
assert!(kill(&s, &serde_json::json!({ "id": 999u32 })).is_ok());
}
}