use crate::agent::{self, AgentConfig};
use crate::session::write_frame;
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::io::{BufRead, BufReader};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::time::Duration;
const BROKER_VERSION: &str = "1";
pub const INSTALL_SH: &str = include_str!("../install.sh");
#[derive(Serialize, Deserialize)]
pub enum BrokerRequest {
Chat {
version: String,
model: Option<String>,
messages: Vec<serde_json::Value>,
max_tokens: u32,
temperature: f64,
#[serde(default)]
host: Option<String>,
#[serde(default)]
session: Option<String>,
},
}
#[derive(Serialize, Deserialize)]
pub enum BrokerResponse {
Chat { text: String },
Error { message: String },
}
pub fn broker_socket_path() -> Result<PathBuf> {
let home = std::env::var("HOME").map_err(|_| anyhow::anyhow!("HOME is not set"))?;
let dir = PathBuf::from(home).join(".mars");
std::fs::create_dir_all(&dir)?;
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&dir, std::fs::Permissions::from_mode(0o700))?;
Ok(dir.join("auth.sock"))
}
pub fn remote_socket_path() -> String {
let uid = unsafe { libc::getuid() };
format!("/tmp/mars-auth-{uid}.sock")
}
pub fn probe_and_sweep(path: &std::path::Path) -> bool {
if UnixStream::connect(path).is_ok() {
return true;
}
if path.exists() {
let _ = std::fs::remove_file(path);
}
false
}
pub fn detect_broker_sock() -> Option<String> {
if let Ok(s) = std::env::var("MARS_AUTH_SOCK") {
if !s.is_empty() {
return Some(s);
}
}
find_live_auth_sock(std::path::Path::new("/tmp"))
}
pub fn find_live_auth_sock(dir: &std::path::Path) -> Option<String> {
let own = dir.join(format!("mars-auth-{}.sock", unsafe { libc::getuid() }));
if probe_and_sweep(&own) {
return Some(own.to_string_lossy().into_owned());
}
let mut candidates: Vec<_> = std::fs::read_dir(dir)
.ok()?
.flatten()
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.starts_with("mars-auth-") && n.ends_with(".sock"))
})
.collect();
candidates.sort();
candidates
.into_iter()
.find(|p| probe_and_sweep(p))
.map(|p| p.to_string_lossy().into_owned())
}
pub fn keyd_main() -> Result<()> {
let cfg = AgentConfig::from_env();
if !cfg.is_configured() {
anyhow::bail!(
"mars keyd: no API key found. Set GROQ_API_KEY / GEMINI_API_KEY / MARS_LLM_KEY \
on this machine first."
);
}
let path = broker_socket_path()?;
if path.exists() && UnixStream::connect(&path).is_err() {
let _ = std::fs::remove_file(&path);
}
let listener = UnixListener::bind(&path)
.map_err(|e| anyhow::anyhow!("mars keyd: cannot bind {}: {e}", path.display()))?;
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
println!(
"mars keyd: broker listening (provider: {}) at {}",
cfg.provider,
path.display()
);
println!(" now run: mars ssh <host> — the agent works there, no key on the box.");
for conn in listener.incoming() {
match conn {
Ok(stream) => {
std::thread::spawn(move || {
let _ = handle_conn(stream);
});
}
Err(_) => continue,
}
}
Ok(())
}
fn handle_conn(stream: UnixStream) -> Result<()> {
let mut reader = BufReader::new(stream.try_clone()?);
let mut w = stream;
let mut line = String::new();
loop {
line.clear();
if reader.read_line(&mut line)? == 0 {
break; }
let req: BrokerRequest = match serde_json::from_str(line.trim()) {
Ok(r) => r,
Err(_) => continue,
};
let BrokerRequest::Chat { version, model, messages, max_tokens, temperature, host, session } =
req;
if let Some(h) = &host {
fleet_status(h, session, "agent active");
}
let resp = if version != BROKER_VERSION {
BrokerResponse::Error {
message: format!("broker version mismatch (home {BROKER_VERSION}, remote {version})"),
}
} else {
let mut c = AgentConfig::from_env();
if let Some(m) = model {
c.model = m;
}
c.max_tokens = max_tokens;
c.temperature = temperature;
match agent::chat(&c, messages, "remote") {
Ok(text) => BrokerResponse::Chat { text },
Err(e) => BrokerResponse::Error { message: e.to_string() },
}
};
write_frame(&mut w, &resp)?;
}
Ok(())
}
pub fn chat_via_broker(
sock: &str,
cfg: &AgentConfig,
messages: Vec<serde_json::Value>,
) -> Result<String> {
let stream = UnixStream::connect(sock)
.map_err(|e| anyhow::anyhow!("home broker unreachable ({e}); is `mars keyd` running + the tunnel up?"))?;
stream.set_read_timeout(Some(Duration::from_secs(40)))?;
let mut reader = BufReader::new(stream.try_clone()?);
let mut w = stream;
let model = if cfg.model.is_empty() { None } else { Some(cfg.model.clone()) };
write_frame(
&mut w,
&BrokerRequest::Chat {
version: BROKER_VERSION.to_string(),
model,
messages,
max_tokens: cfg.max_tokens,
temperature: cfg.temperature,
host: hostname(),
session: std::env::var("MARS_SESSION").ok(),
},
)?;
let mut line = String::new();
reader.read_line(&mut line)?;
match serde_json::from_str::<BrokerResponse>(line.trim())
.map_err(|e| anyhow::anyhow!("broker sent a malformed reply: {e}"))?
{
BrokerResponse::Chat { text } => Ok(text),
BrokerResponse::Error { message } => anyhow::bail!("{message}"),
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct FleetEntry {
pub host: String,
pub cwd: Option<String>,
pub session: Option<String>,
pub last_status: Option<String>,
pub as_of: u64,
}
fn fleet_path() -> Result<PathBuf> {
Ok(broker_socket_path()?.with_file_name("fleet.json"))
}
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
pub fn fleet_load() -> Vec<FleetEntry> {
let mut v: Vec<FleetEntry> = fleet_path()
.ok()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default();
v.sort_by(|a, b| b.as_of.cmp(&a.as_of));
v
}
pub fn fleet_record(host: &str, cwd: Option<String>) {
let mut v = fleet_load();
match v.iter_mut().find(|e| e.host == host) {
Some(e) => {
e.as_of = now_secs();
if cwd.is_some() {
e.cwd = cwd;
}
}
None => v.push(FleetEntry {
host: host.to_string(),
cwd,
session: None,
last_status: None,
as_of: now_secs(),
}),
}
fleet_save(v);
}
pub fn fleet_status(host: &str, session: Option<String>, status: &str) {
let mut v = fleet_load();
match v.iter_mut().find(|e| e.host == host) {
Some(e) => {
e.as_of = now_secs();
e.last_status = Some(status.to_string());
if session.is_some() {
e.session = session;
}
}
None => v.push(FleetEntry {
host: host.to_string(),
cwd: None,
session,
last_status: Some(status.to_string()),
as_of: now_secs(),
}),
}
fleet_save(v);
}
fn fleet_save(mut v: Vec<FleetEntry>) {
v.sort_by(|a, b| b.as_of.cmp(&a.as_of));
v.truncate(50);
if let Ok(p) = fleet_path() {
if let Ok(s) = serde_json::to_string_pretty(&v) {
let _ = std::fs::write(p, s);
}
}
}
fn hostname() -> Option<String> {
let mut buf = [0u8; 256];
let ok =
unsafe { libc::gethostname(buf.as_mut_ptr() as *mut libc::c_char, buf.len()) } == 0;
if !ok {
return None;
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
let name = String::from_utf8_lossy(&buf[..end]).trim().to_string();
if name.is_empty() { None } else { Some(name) }
}
pub fn ago(as_of: u64) -> String {
let secs = now_secs().saturating_sub(as_of);
if secs < 60 {
"just now".into()
} else if secs < 3600 {
format!("{}m ago", secs / 60)
} else if secs < 86_400 {
format!("{}h ago", secs / 3600)
} else {
format!("{}d ago", secs / 86_400)
}
}
pub fn resolve_target(hosts: &[String], input: &str) -> Option<String> {
let t = input.trim();
if t.is_empty() || t == "q" {
return None;
}
if let Ok(n) = t.parse::<usize>() {
return hosts.get(n.checked_sub(1)?).cloned();
}
if let Some(h) = hosts.iter().find(|h| h.as_str() == t) {
return Some(h.clone());
}
let pre: Vec<&String> = hosts.iter().filter(|h| h.starts_with(t)).collect();
if pre.len() == 1 {
Some(pre[0].clone())
} else {
None
}
}
fn ensure_keyd(home_sock: &std::path::Path) -> bool {
if UnixStream::connect(home_sock).is_ok() {
return true; }
if !AgentConfig::from_env().is_configured() {
eprintln!(
"mars ssh: no API key in this shell, so the remote agent won't have one.\n \
set GROQ_API_KEY / GEMINI_API_KEY here (then it auto-starts), or run `mars keyd` \
where your key lives."
);
return false;
}
let _ = std::fs::remove_file(home_sock); let exe = match std::env::current_exe() {
Ok(e) => e,
Err(_) => return false,
};
let mut cmd = std::process::Command::new(exe);
cmd.arg("keyd");
cmd.env_remove("MARS_AUTH_SOCK"); let log = home_sock.with_file_name("keyd.log");
match std::fs::OpenOptions::new().create(true).append(true).open(&log) {
Ok(f) => {
let f2 = f.try_clone().ok();
cmd.stdout(f);
match f2 {
Some(f2) => { cmd.stderr(f2); }
None => { cmd.stderr(std::process::Stdio::null()); }
}
}
Err(_) => {
cmd.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null());
}
}
cmd.stdin(std::process::Stdio::null());
unsafe {
use std::os::unix::process::CommandExt;
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
if cmd.spawn().is_err() {
return false;
}
for _ in 0..40 {
std::thread::sleep(Duration::from_millis(50));
if UnixStream::connect(home_sock).is_ok() {
eprintln!("mars ssh: started the home broker (mars keyd) automatically.");
return true;
}
}
eprintln!("mars ssh: could not start the home broker (see ~/.mars/keyd.log).");
false
}
pub fn remote_prelude_cmd(remote_sock: &str) -> String {
format!(
"rm -f {remote_sock}; \
mkdir -p ~/.mars && cat > ~/.mars/install.sh && chmod +x ~/.mars/install.sh"
)
}
pub fn remote_session_cmd(remote_sock: &str, pushed: bool) -> String {
let nudge = if pushed {
"printf '[mars] not installed here — installer is ready. Run:\\n sh ~/.mars/install.sh\\n'"
} else {
"printf '[mars] not installed here. Install:\\n \
curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh # Rust toolchain (>=1.85)\\n \
. \"$HOME/.cargo/env\" && cargo install mars-terminal --locked\\n'"
};
format!(
"if [ -S {remote_sock} ]; then \
printf '[mars] agent tunnel ready — your home key answers here\\n'; else \
printf '[mars] no agent tunnel (forward failed?) — the agent needs a key on this box\\n'; fi; \
M=\"$(command -v mars 2>/dev/null)\"; \
if [ -z \"$M\" ] && [ -x \"$HOME/.cargo/bin/mars\" ]; then M=\"$HOME/.cargo/bin/mars\"; fi; \
if [ -z \"$M\" ] && [ -x \"$HOME/.local/bin/mars\" ]; then M=\"$HOME/.local/bin/mars\"; fi; \
export MARS_AUTH_SOCK={remote_sock}; \
if [ -n \"$M\" ]; then \"$M\" attach 2>/dev/null || exec \"$M\" new main; else \
{nudge}; exec ${{SHELL:-/bin/sh}} -l; fi"
)
}
pub fn ssh_main(host: String, extra: Vec<String>) -> Result<()> {
let home_sock = broker_socket_path()?;
ensure_keyd(&home_sock);
fleet_record(&host, None); let remote_sock = remote_socket_path();
let control = home_sock.with_file_name("cm-%r@%h:%p");
if let Some(dir) = control.parent() {
if let Ok(entries) = std::fs::read_dir(dir) {
for e in entries.flatten() {
let name = e.file_name().to_string_lossy().to_string();
if !name.starts_with("cm-") {
continue;
}
let alive = std::process::Command::new("ssh")
.arg("-O").arg("check")
.arg("-o").arg(format!("ControlPath={}", e.path().display()))
.arg("stale-check")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
if !alive {
let _ = std::fs::remove_file(e.path());
}
}
}
}
let pushed = {
use std::io::Write as _;
let mut child = std::process::Command::new("ssh")
.arg("-o").arg("ControlMaster=auto")
.arg("-o").arg("ControlPersist=60s")
.arg("-o").arg("ServerAliveInterval=30")
.arg("-o").arg("ServerAliveCountMax=3")
.arg("-o").arg(format!("ControlPath={}", control.display()))
.args(&extra)
.arg(&host)
.arg(remote_prelude_cmd(&remote_sock))
.stdin(std::process::Stdio::piped())
.spawn()
.ok();
match child.as_mut() {
Some(c) => {
let ok_write = c
.stdin
.take()
.and_then(|mut s| s.write_all(INSTALL_SH.as_bytes()).ok())
.is_some();
let ok_exit = c.wait().map(|s| s.success()).unwrap_or(false);
ok_write && ok_exit
}
None => false,
}
};
if !pushed {
eprintln!("mars ssh: note — couldn't drop the installer on the remote (continuing).");
}
let remote_cmd = remote_session_cmd(&remote_sock, pushed);
let status = std::process::Command::new("ssh")
.arg("-o").arg("StreamLocalBindUnlink=yes")
.arg("-o").arg("ControlMaster=auto")
.arg("-o").arg("ControlPersist=60s")
.arg("-o").arg("ServerAliveInterval=30")
.arg("-o").arg("ServerAliveCountMax=3")
.arg("-o").arg(format!("ControlPath={}", control.display()))
.arg("-R").arg(format!("{remote_sock}:{}", home_sock.display()))
.args(&extra)
.arg("-t")
.arg(&host)
.arg(&remote_cmd)
.status()
.map_err(|e| anyhow::anyhow!("mars ssh: could not launch ssh: {e}"))?;
std::process::exit(status.code().unwrap_or(1));
}