use std::io::BufRead;
use std::path::PathBuf;
use anyhow::{Context, Result};
use colored::Colorize;
use serde::Serialize;
use tsafe_cli::cli::AgentAction;
use tsafe_core::{agent, profile};
#[derive(Serialize)]
struct AgentStatusJson {
version: &'static str,
agent_running: bool,
vault_locked: bool,
active_profile: String,
biometric_enabled: bool,
session_expires_at: Option<String>,
idle_ttl_remaining_secs: Option<u64>,
absolute_ttl_remaining_secs: Option<u64>,
agent_pid: Option<u32>,
}
pub(crate) fn parse_ttl(s: &str) -> Option<u64> {
if let Some(n) = s.strip_suffix('m') {
return n.parse::<u64>().ok().map(|v| v * 60);
}
if let Some(n) = s.strip_suffix('h') {
return n.parse::<u64>().ok().map(|v| v * 3600);
}
if let Some(n) = s.strip_suffix('s') {
return n.parse::<u64>().ok();
}
s.parse::<u64>().ok()
}
fn tsafe_agent_filename() -> &'static str {
if cfg!(windows) {
"tsafe-agent.exe"
} else {
"tsafe-agent"
}
}
fn resolve_tsafe_agent_binary() -> Result<PathBuf> {
let name = tsafe_agent_filename();
if let Ok(path) = std::env::var("TSAFE_AGENT_BIN") {
let candidate = PathBuf::from(path);
if candidate.is_file() {
return Ok(candidate);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let candidate = dir.join(name);
if candidate.is_file() {
return Ok(candidate);
}
}
}
if let Some(hit) = find_on_path(name) {
return Ok(PathBuf::from(hit));
}
let tsafe = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "(could not read tsafe path)".to_string());
anyhow::bail!(
"tsafe-agent binary not found.\n\
\n\
Looked at TSAFE_AGENT_BIN, next to this tsafe ({tsafe}), and on your PATH for `{name}`.\n\
\n\
Install tsafe-agent via the release channel or companion-binary path that ships it for your stack.\n\
That can be a bundle that places tsafe-agent next to tsafe or a separate companion install that adds it to PATH.\n\
Development builds: `cargo build -p tsafe-agent` places tsafe-agent in the same\n\
target/*/debug (or release) directory as tsafe."
);
}
fn find_on_path(filename: &str) -> Option<String> {
std::env::var_os("PATH").and_then(|path_var| {
std::env::split_paths(&path_var).find_map(|dir| {
let candidate = dir.join(filename);
if candidate.is_file() {
candidate.to_str().map(|s| s.to_string())
} else {
None
}
})
})
}
#[cfg(target_os = "macos")]
fn applescript_escape(s: &str) -> String {
s.replace('\\', "\\\\").replace('"', "\\\"")
}
pub(crate) fn cmd_agent(profile: &str, action: AgentAction) -> Result<()> {
match action {
AgentAction::Unlock { ttl, absolute_ttl } => cmd_agent_unlock(profile, &ttl, &absolute_ttl),
AgentAction::Lock => cmd_agent_lock(),
AgentAction::Status { json } => {
if json {
cmd_agent_status_json(profile)
} else {
cmd_agent_status_human()
}
}
}
}
fn cmd_agent_unlock(profile: &str, ttl: &str, absolute_ttl: &str) -> Result<()> {
use std::process::Stdio;
let idle_ttl_secs = parse_ttl(ttl)
.ok_or_else(|| anyhow::anyhow!("invalid idle TTL '{ttl}' — use e.g. 15m, 1h, 4h"))?;
let absolute_ttl_secs = parse_ttl(absolute_ttl).ok_or_else(|| {
anyhow::anyhow!("invalid absolute TTL '{absolute_ttl}' — use e.g. 8h, 12h, 24h")
})?;
anyhow::ensure!(
absolute_ttl_secs >= idle_ttl_secs,
"--absolute-ttl ({absolute_ttl}) must be >= --ttl ({ttl})"
);
let vault_path = profile::vault_path(profile);
anyhow::ensure!(
vault_path.exists(),
"no vault for profile '{profile}'. Create one with: tsafe --profile {profile} init"
);
let token_hex: String = {
use rand::RngCore;
let mut b = [0u8; 32];
rand::rngs::OsRng.fill_bytes(&mut b);
b.iter().map(|byte| format!("{byte:02x}")).collect()
};
let my_pid = std::process::id();
let agent_bin = resolve_tsafe_agent_binary()?;
let notify_msg = format!(
"PID {my_pid} requests vault unlock for profile \"{profile}\". Use this terminal as source of truth; any desktop banner is only a hint."
);
eprintln!("{notify_msg}");
#[cfg(target_os = "windows")]
{
let ps_cmd = format!(
r#"Import-Module BurntToast -ErrorAction SilentlyContinue; New-BurntToastNotification -Text 'tsafe Agent', '{notify_msg}' -AppId 'tsafe' 2>$null; exit 0"#
);
let _ = std::process::Command::new("powershell.exe")
.args(["-NonInteractive", "-Command", &ps_cmd])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
#[cfg(target_os = "macos")]
{
let title = format!("tsafe · PID {my_pid} · profile {profile}");
let body = "Approve by entering your vault password in the terminal.";
let script = format!(
"display notification \"{}\" with title \"{}\"",
applescript_escape(body),
applescript_escape(&title)
);
let _ = std::process::Command::new("osascript")
.args(["-e", &script])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
{
let _ = std::process::Command::new("notify-send")
.args(["tsafe Agent", ¬ify_msg])
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
}
eprintln!("Enter your vault password here to approve and start the agent.");
let mut child = std::process::Command::new(&agent_bin)
.args([
profile,
&token_hex,
&my_pid.to_string(),
&idle_ttl_secs.to_string(),
&absolute_ttl_secs.to_string(),
])
.stdout(Stdio::piped())
.stdin(Stdio::inherit())
.stderr(Stdio::inherit())
.spawn()
.with_context(|| format!("failed to start {}", agent_bin.display()))?;
let stdout = child.stdout.take().expect("piped stdout");
let mut reader = std::io::BufReader::new(stdout);
let mut line = String::new();
reader
.read_line(&mut line)
.context("agent did not emit TSAFE_AGENT_SOCK")?;
let line = line.trim();
if !line.starts_with(agent::ENV_AGENT_SOCK) {
anyhow::bail!("agent emitted unexpected output: {line}");
}
let sock_val = line
.split_once('=')
.map(|(_, v)| v)
.ok_or_else(|| anyhow::anyhow!("malformed agent output: {line}"))?
.trim_matches('"');
println!("# Run one of the following in your shell to activate the agent:");
println!("$env:TSAFE_AGENT_SOCK = \"{sock_val}\" # PowerShell");
println!("export TSAFE_AGENT_SOCK=\"{sock_val}\" # bash/zsh");
Ok(())
}
fn cmd_agent_lock() -> Result<()> {
agent::send_lock().map_err(|e| anyhow::anyhow!("lock failed: {e}"))?;
println!("{} Agent session revoked.", "✓".green());
Ok(())
}
fn cmd_agent_status_human() -> Result<()> {
let env_sock = agent::read_agent_sock_env();
match env_sock.clone().or_else(agent::read_agent_sock) {
None => {
println!("No agent running (no TSAFE_AGENT_SOCK and no persisted agent state).");
}
Some(sock) => {
match agent::ping_agent(&sock) {
Ok(true) => {
if env_sock.is_some() {
println!("{} Agent socket is reachable ({})", "✓".green(), sock);
} else {
println!(
"{} Agent socket is reachable via persisted state ({})",
"✓".green(),
sock
);
}
println!(
" Reachability does not confirm that the session was unlocked for the current --profile."
);
}
Ok(false) => println!("{} Agent pipe found but ping failed.", "✗".red()),
Err(e) => println!("{} Agent unreachable: {e}", "✗".red()),
}
}
}
Ok(())
}
fn cmd_agent_status_json(active_profile: &str) -> Result<()> {
let sock = agent::read_agent_sock_env().or_else(agent::read_agent_sock);
let (agent_running, agent_pid) = match &sock {
None => (false, None),
Some(s) => {
let pid = parse_agent_pid_from_sock(s);
let running = matches!(agent::ping_agent(s), Ok(true));
(running, pid)
}
};
let vault_locked = !agent_running;
let biometric_enabled = biometric_has_password(active_profile);
let status = AgentStatusJson {
version: "1",
agent_running,
vault_locked,
active_profile: active_profile.to_string(),
biometric_enabled,
session_expires_at: None,
idle_ttl_remaining_secs: None,
absolute_ttl_remaining_secs: None,
agent_pid,
};
let json = serde_json::to_string_pretty(&status)
.context("failed to serialize agent status to JSON")?;
println!("{json}");
Ok(())
}
fn parse_agent_pid_from_sock(sock: &str) -> Option<u32> {
let pipe = agent::parse_agent_sock(sock).map(|(p, _)| p)?;
let marker = "tsafe-agent-";
let idx = pipe.rfind(marker)?;
let after = &pipe[idx + marker.len()..];
let digits: String = after.chars().take_while(|c| c.is_ascii_digit()).collect();
digits.parse::<u32>().ok()
}
fn biometric_has_password(profile: &str) -> bool {
#[cfg(feature = "biometric")]
{
tsafe_core::keyring_store::has_password(profile)
}
#[cfg(not(feature = "biometric"))]
{
let _ = profile;
false
}
}