pub mod display;
use std::{
io::{BufRead, BufReader, Write},
os::unix::net::{UnixListener, UnixStream},
path::PathBuf,
thread,
time::{Duration, Instant},
};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use crate::{broker, fs_util, logs};
use base64::Engine as _;
pub fn human_run_dir(shell_pid: u32) -> PathBuf {
logs::ward_home()
.join("run")
.join(format!("human-{shell_pid}"))
}
pub fn guardian_socket_path(shell_pid: u32) -> PathBuf {
human_run_dir(shell_pid).join("guardian.sock")
}
fn ready_marker_path(shell_pid: u32) -> PathBuf {
human_run_dir(shell_pid).join("ready")
}
pub fn parent_pid() -> u32 {
#[cfg(unix)]
{
unsafe { libc::getppid() as u32 }
}
#[cfg(not(unix))]
{
0
}
}
pub fn current_shell_pid() -> u32 {
std::env::var("WARD_HUMAN_SHELL_PID")
.ok()
.and_then(|raw| raw.parse::<u32>().ok())
.filter(|pid| *pid != 0)
.unwrap_or_else(parent_pid)
}
pub fn is_human_terminal() -> bool {
guardian_socket_path(current_shell_pid()).exists()
}
#[derive(Debug, Clone)]
pub struct RuntimeDiagnostics {
pub shell_pid: u32,
pub socket_path: PathBuf,
pub shell_hooks_loaded: bool,
pub guardian_socket_exists: bool,
pub stale_guardian_pids: Vec<u32>,
pub stale_run_dirs: Vec<PathBuf>,
}
#[derive(Debug, Clone)]
struct GuardianProcess {
pid: u32,
shell_pid: u32,
}
pub fn runtime_diagnostics() -> RuntimeDiagnostics {
let shell_pid = current_shell_pid();
let socket_path = guardian_socket_path(shell_pid);
RuntimeDiagnostics {
shell_pid,
guardian_socket_exists: socket_path.exists(),
socket_path,
shell_hooks_loaded: std::env::var_os("WARD_SHELL_INTEGRATION").is_some(),
stale_guardian_pids: stale_guardian_processes()
.into_iter()
.map(|guardian| guardian.pid)
.collect(),
stale_run_dirs: stale_human_run_dirs(),
}
}
pub fn cleanup_stale_runtime() -> RuntimeDiagnostics {
let diagnostics = runtime_diagnostics();
for pid in &diagnostics.stale_guardian_pids {
terminate_process(*pid);
}
for dir in &diagnostics.stale_run_dirs {
let _ = std::fs::remove_dir_all(dir);
}
diagnostics
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum GuardianRequest {
Shutdown,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum GuardianResponse {
Ok,
Error { reason: String, message: String },
}
fn write_guardian_response(stream: &mut UnixStream, resp: &GuardianResponse) -> Result<()> {
let mut line = serde_json::to_string(resp)?;
line.push('\n');
stream.write_all(line.as_bytes())?;
Ok(())
}
pub fn serve_guardian(shell_pid: u32, session_token: &str, ttl_seconds: i64) -> Result<()> {
let dir = human_run_dir(shell_pid);
let socket_path = guardian_socket_path(shell_pid);
let ready_path = ready_marker_path(shell_pid);
if socket_path.exists() {
let _ = std::fs::remove_file(&socket_path);
}
fs_util::ensure_private_dir(&dir)?;
let listener = UnixListener::bind(&socket_path).with_context(|| {
format!(
"failed to bind guardian socket at {}",
socket_path.display()
)
})?;
listener.set_nonblocking(true)?;
broker::ensure_running()?;
broker::register_human_session(shell_pid, session_token, ttl_seconds)?;
fs_util::write_private_file(&ready_path, b"")?;
let deadline = Instant::now() + Duration::from_secs(ttl_seconds.max(0) as u64);
'accept: loop {
if Instant::now() >= deadline {
break;
}
if !process_exists(shell_pid) {
break;
}
match listener.accept() {
Ok((mut stream, _)) => {
let mut reader = BufReader::new(stream.try_clone()?);
let mut line = String::new();
if reader.read_line(&mut line).is_ok() {
match serde_json::from_str::<GuardianRequest>(line.trim()) {
Ok(GuardianRequest::Shutdown) => {
let _ = write_guardian_response(&mut stream, &GuardianResponse::Ok);
break 'accept;
}
Err(_) => {
let _ = write_guardian_response(
&mut stream,
&GuardianResponse::Error {
reason: "unknown_request".into(),
message: "unrecognised guardian request".into(),
},
);
}
}
}
}
Err(ref e) if e.kind() == std::io::ErrorKind::WouldBlock => {
thread::sleep(Duration::from_millis(100));
}
Err(_) => break,
}
}
let _ = broker::deregister_human_session(shell_pid, session_token);
let _ = std::fs::remove_file(&socket_path);
let _ = std::fs::remove_file(&ready_path);
let _ = std::fs::remove_dir(&dir);
Ok(())
}
pub fn process_exists(pid: u32) -> bool {
if pid == 0 {
return false;
}
#[cfg(unix)]
{
let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
}
#[cfg(not(unix))]
{
true
}
}
pub fn send_guardian_shutdown() -> Result<()> {
let socket_path = guardian_socket_path(current_shell_pid());
if !socket_path.exists() {
return Ok(());
}
let mut stream =
UnixStream::connect(&socket_path).context("failed to connect to human guardian socket")?;
let mut msg = serde_json::to_string(&GuardianRequest::Shutdown)?;
msg.push('\n');
stream.write_all(msg.as_bytes())?;
let mut reader = BufReader::new(stream);
let mut _line = String::new();
let _ = reader.read_line(&mut _line);
Ok(())
}
pub fn activate_human_mode(ttl: &str) -> Result<()> {
use crate::{logs::LogKind, registry, unlock, vault};
let cwd = std::env::current_dir()?;
let passphrase = vault::read_existing_passphrase()?;
let resolved = registry::resolve_project_with_passphrase(None, &cwd, &passphrase)?;
registry::update_project_vault(
&resolved.name,
resolved.path.clone(),
resolved.vault.clone(),
)?;
let duration = unlock::parse_ttl(ttl)?;
let ttl_seconds = duration.num_seconds();
crate::cli::create_run_unlock_session(&resolved.name, &resolved.vault, &passphrase, ttl, None)
.context("incorrect passphrase — human mode not activated")?;
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut bytes);
let session_token = base64::engine::general_purpose::STANDARD.encode(bytes);
let shell_pid = parent_pid();
warn_if_shell_hooks_not_loaded();
let _ = cleanup_stale_runtime();
terminate_existing_guardians(shell_pid);
let stale_socket = guardian_socket_path(shell_pid);
if stale_socket.exists() {
let _ = std::fs::remove_file(&stale_socket);
}
let _ = std::fs::remove_dir_all(human_run_dir(shell_pid));
let exe = std::env::current_exe().context("cannot locate ward binary")?;
std::process::Command::new(&exe)
.arg("__human-guardian")
.arg("--shell-pid")
.arg(shell_pid.to_string())
.arg("--session-token")
.arg(&session_token)
.arg("--ttl-seconds")
.arg(ttl_seconds.to_string())
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.spawn()
.context("failed to spawn human guardian")?;
let ready = ready_marker_path(shell_pid);
let deadline = Instant::now() + Duration::from_secs(3);
while !ready.exists() {
if Instant::now() >= deadline {
anyhow::bail!("human guardian did not become ready in time");
}
thread::sleep(Duration::from_millis(25));
}
let expires_at = (chrono::Utc::now() + duration).to_rfc3339();
let ttl_label = format_ttl_label(ttl_seconds);
println!(
"{}",
display::format_session_prefix(&resolved.name, &ttl_label)
);
if let Ok(instances) = crate::webui::dashboard_diagnostics() {
if let Some(instance) = instances.first() {
println!(" ◆ dashboard → {}", instance.url);
}
}
#[derive(serde::Serialize)]
struct HumanModeEvent {
event_type: &'static str,
shell_pid: u32,
expires_at: String,
}
crate::logs::append_event(
LogKind::Sessions,
HumanModeEvent {
event_type: "human_mode.activated",
shell_pid,
expires_at,
},
)?;
Ok(())
}
fn warn_if_shell_hooks_not_loaded() {
if std::env::var_os("WARD_SHELL_INTEGRATION").is_some() {
return;
}
eprintln!(
"Warning: Ward shell hooks are not loaded in this shell. Reload your shell after installing shell integration, then run `ward human` again if commands like pnpm are not wrapped."
);
}
fn terminate_existing_guardians(shell_pid: u32) {
#[cfg(unix)]
{
let current_pid = std::process::id();
for guardian in guardian_processes() {
if guardian.shell_pid != shell_pid || guardian.pid == current_pid {
continue;
}
terminate_process(guardian.pid);
}
}
}
#[cfg(unix)]
fn guardian_processes() -> Vec<GuardianProcess> {
let Ok(output) = std::process::Command::new("ps")
.args(["-axo", "pid=,command="])
.output()
else {
return Vec::new();
};
let stdout = String::from_utf8_lossy(&output.stdout);
stdout
.lines()
.filter(|line| line.contains("__human-guardian"))
.filter_map(parse_guardian_process)
.collect()
}
#[cfg(not(unix))]
fn guardian_processes() -> Vec<GuardianProcess> {
Vec::new()
}
fn parse_guardian_process(line: &str) -> Option<GuardianProcess> {
let parts = line.split_whitespace().collect::<Vec<_>>();
let pid = parts.first()?.parse::<u32>().ok()?;
let shell_pid = parts
.windows(2)
.find_map(|window| (window[0] == "--shell-pid").then(|| window[1]))
.and_then(|raw| raw.parse::<u32>().ok())?;
Some(GuardianProcess { pid, shell_pid })
}
fn stale_guardian_processes() -> Vec<GuardianProcess> {
guardian_processes()
.into_iter()
.filter(|guardian| !process_exists(guardian.shell_pid))
.collect()
}
fn stale_human_run_dirs() -> Vec<PathBuf> {
let run_dir = logs::ward_home().join("run");
let Ok(entries) = std::fs::read_dir(run_dir) else {
return Vec::new();
};
entries
.filter_map(std::result::Result::ok)
.map(|entry| entry.path())
.filter(|path| path.is_dir())
.filter_map(|path| {
let shell_pid = path
.file_name()
.and_then(|name| name.to_str())
.and_then(|name| name.strip_prefix("human-"))
.and_then(|raw| raw.parse::<u32>().ok())?;
if !process_exists(shell_pid) || !path.join("guardian.sock").exists() {
Some(path)
} else {
None
}
})
.collect()
}
fn terminate_process(pid: u32) {
#[cfg(unix)]
{
let _ = unsafe { libc::kill(pid as libc::pid_t, libc::SIGTERM) };
}
#[cfg(not(unix))]
{
let _ = pid;
}
}
fn format_ttl_label(ttl_seconds: i64) -> String {
let h = ttl_seconds / 3600;
let m = (ttl_seconds % 3600) / 60;
if m == 0 {
format!("{h}h")
} else if h == 0 {
format!("{m}m")
} else {
format!("{h}h {m}m")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn path_helpers_use_shell_pid() {
let dir = human_run_dir(4821);
assert!(dir.to_string_lossy().contains("human-4821"));
let sock = guardian_socket_path(4821);
assert!(sock.to_string_lossy().ends_with("human-4821/guardian.sock"));
}
#[test]
fn is_human_terminal_false_when_no_socket() {
assert!(!guardian_socket_path(9999999).exists());
}
#[test]
fn ttl_label_formatting() {
assert_eq!(format_ttl_label(28800), "8h");
assert_eq!(format_ttl_label(3600), "1h");
assert_eq!(format_ttl_label(5400), "1h 30m");
assert_eq!(format_ttl_label(1800), "30m");
}
}