//! UFO local rover.
//!
//! Long-poll claims queued runs from the Hub, lets the assigned pilot
//! drive the rover, streams typed messages back, captures
//! the resulting `git diff`, and reports a terminal state.
use std::collections::HashMap;
use std::fs;
#[cfg(unix)]
use std::fs::OpenOptions;
use std::io::{self, IsTerminal, Write};
#[cfg(unix)]
use std::os::unix::fs::{OpenOptionsExt, PermissionsExt};
use std::path::{Path, PathBuf};
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use clap::{Parser, Subcommand};
use reqwest::{Client, StatusCode};
use serde::{Deserialize, Serialize};
use serde_json::json;
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use tokio::sync::Semaphore;
use tokio::task::JoinSet;
use tokio::time::sleep;
static CONFIG_LOCK: Mutex<()> = Mutex::new(());
/// Current local time in hub-log style, e.g. `2026-06-07 23:15:42.123 -0700`.
fn ts() -> String {
chrono::Local::now()
.format("%Y-%m-%d %H:%M:%S%.3f %z")
.to_string()
}
/// Print a timestamped log line.
macro_rules! logline {
($($arg:tt)*) => {{
println!("{} {}", ts(), format!($($arg)*))
}};
}
const ROVER_BANNER: &str = r#"
█▀▀█ █▀▀█ ▄▀▀▀▀▀▀▀▀█ ▄▀▀▀▀▀▀▀▄ █▀▀▀▀▀▀▀▀▄ ▄▀▀▀▀▀▀▀▄ █▀▀█ █▀▀█ ▄▀▀▀▀▀▀▀▀█ █▀▀▀▀▀▀▀▀▄
█ █ █ █ █ █▄▄▄▄▄▄█ █ ▄▄▄ █ █ ▄▄▄▄ █ █ ▄▄▄ █ █ █ █ █ █ █▄▄▄▄▄▄█ █ ▄▄▄▄ █
█ █ █ █ █ █▄▄▄▄▄ █ █ █ █ █ █▄▄▄▀ █ █ █ █ █ █ █ █ █ █ █▄▄▄▄▄ █ █▄▄▄▀ █
▀ ▀ ▄ ▄ ▀ █ ▀ ▀ ▄ ▄ ▀ ▄ ▀ ▀ ▄ ▄ ▀ ▀ ▄ ▄ ▀ █ ▀ ▄
█ █ █ █ █ █▀▀▀▀▀ █ █ █ █ █ █▀▀▀▄ █ █ █ █ █ █ ▀▄ ▄▀ █ █ █▀▀▀▀▀ █ █▀▀▀▄ █
█ ▀▀▀ █ █ █ █ ▀▀▀ █ █ █ █ █ █ ▀▀▀ █ ▀▄ █▀█ ▄▀ █ █▀▀▀▀▀▀█ █ █ █ █
▀▄▄▄▄▄▄▄▀ █▄▄█ ▀▄▄▄▄▄▄▄▀ █▄▄█ █▄▄█ ▀▄▄▄▄▄▄▄▀ ▀▄▄▄▄▄▀ ▀▄▄▄▄▄▄▄▄█ █▄▄█ █▄▄█
"#;
const ROVER_BANNER_COLOR: &str = concat!(
"\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[0m\n",
"\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;0;0m▄\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;0;0m▄\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;0;0m▄\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;0;0m▄\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[0m\n",
"\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;0;170;170;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m▄\x1b[38;2;255;255;255;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;0;170;170;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m▄\x1b[38;2;255;255;255;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[0m\n",
"\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;170;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;170;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[0m\n",
"\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;170;170;170m▀\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m▄\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;170;170;170m▄\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;170;170;170m▀\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m▄\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;170;170;170m▄\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;170;170;170m▄\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;170;170;170m▀\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m▄\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;170;170;170m▄\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;170;170;170m▀\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m▄\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;170;170;170m▄\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;170;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;170;170;170m▄\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[0m\n",
"\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;0;0m▀\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m▀\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;170;170m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;0;0m▄\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;255;255;255;48;2;0;0;0m▄\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;0;0m▀\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m▀\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[0m\n",
"\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;85;85;85;48;2;0;0;0m█\x1b[38;2;85;85;85;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;0;170;170;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;170;170m▀\x1b[38;2;0;170;170;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;255;255;255;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;0;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;170;170;170;48;2;0;170;170m▀\x1b[38;2;85;85;85;48;2;0;170;170m▀\x1b[38;2;85;85;85;48;2;0;170;170m▀\x1b[38;2;85;85;85;48;2;0;170;170m▀\x1b[38;2;85;85;85;48;2;0;170;170m▀\x1b[38;2;85;85;85;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;170;170m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;85;85;85;48;2;0;0;0m█\x1b[38;2;85;85;85;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;170;170m \x1b[38;2;85;85;85;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[0m\n",
"\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;85;85;85;48;2;0;0;0m█\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m▀\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;170;170m▄\x1b[38;2;170;170;170;48;2;0;0;0m█\x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;170;170;170;48;2;0;0;0m \x1b[38;2;85;85;85;48;2;0;0;0m█\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;170;170m▄\x1b[38;2;85;85;85;48;2;0;0;0m█\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[0m\n",
"\x1b[38;2;255;255;255;48;2;0;0;0m \x1b[0m\x1b[0m",
);
fn print_banner() {
let mut out = io::stdout();
if out.is_terminal() {
let _ = writeln!(out, "{ROVER_BANNER_COLOR}");
} else {
let _ = writeln!(out, "{ROVER_BANNER}");
}
}
#[derive(Parser)]
#[command(name = "ufo", version, about = "UFO local rover")]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Rover controls.
Rover {
#[command(subcommand)]
action: RoverAction,
},
}
#[derive(Subcommand)]
enum RoverAction {
/// Create a new rover enrollment (persisted locally under its hub-assigned id).
Enroll {
#[arg(long, env = "UFO_HUB_UPLINK", default_value = "http://localhost:8080")]
hub: String,
/// Enrollment code (one-time or reusable) from the fleet's Rovers panel.
#[arg(long, env = "UFO_ROVER_ENROLLMENT_CODE", allow_hyphen_values = true)]
enrollment_code: String,
/// Rover name (defaults to the hostname).
#[arg(long)]
name: Option<String>,
/// User tag(s) for dispatch matching; repeatable, e.g. --tag gpu --tag region:us.
#[arg(long = "tag")]
tags: Vec<String>,
},
/// List rover enrollments on this host.
List,
/// Check rover enrollments can reach the hub.
Status {
/// Check just this rover id. Omit to check every enrolled rover.
#[arg(long)]
rover: Option<String>,
},
/// Remove a rover enrollment: delete it hub-side and drop the local entry.
Remove {
/// Rover id or prefix to remove (see `rover list`). Omit with --all to remove every one.
rover: Option<String>,
/// Remove all rover enrollments on this host.
#[arg(long)]
all: bool,
},
/// Run the claim/execute loop for one or (by default) all enrolled rovers.
Start {
/// Run just this rover id. Omit to run every enrolled rover.
#[arg(long)]
rover: Option<String>,
/// Seconds to wait after a failed claim (hub unreachable) before retrying.
#[arg(long, env = "UFO_ROVER_RETRY_SECONDS", default_value_t = 1)]
retry_seconds: u64,
/// Operations a single rover runs at once (parallel units).
#[arg(long, env = "UFO_ROVER_UNITS", default_value_t = 1)]
units: usize,
/// Outpost: this host's rover base directory (default ~/.ufo). Each operation runs in an
/// isolated `<outpost>/rovers/<rover-id>/operations/<operation-id>`.
#[arg(long = "outpost", env = "UFO_ROVER_OUTPOST")]
outpost: Option<PathBuf>,
},
}
/// Rover-facing view of a claimed run (matches the API's ClaimedRun).
#[derive(Debug, Deserialize)]
struct ClaimedRun {
id: String,
operation_id: String,
#[allow(dead_code)]
state: String,
#[serde(default)]
pilot: String,
#[serde(default)]
prompt: String,
#[serde(default)]
session_id: String,
#[serde(default)]
can_propose_sub_operations: bool,
}
#[tokio::main]
async fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Rover { action } => match action {
RoverAction::Enroll {
hub,
enrollment_code,
name,
tags,
} => cmd_enroll(&hub, &enrollment_code, name, tags).await,
RoverAction::List => cmd_list(),
RoverAction::Status { rover } => cmd_status(rover).await,
RoverAction::Remove { rover, all } => cmd_remove(rover, all).await,
RoverAction::Start {
rover,
retry_seconds,
units,
outpost,
} => cmd_start(rover, retry_seconds, units.max(1), outpost).await,
},
}
}
fn default_outpost() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| ".".into());
PathBuf::from(home).join(".ufo")
}
fn http_client() -> Client {
// Timeout must exceed the hub's claim long-poll (default 25s); other calls quick.
Client::builder()
.timeout(Duration::from_secs(60))
.build()
.expect("build http client")
}
// The rover speaks v1; the uplink is the hub origin, so the version is appended here.
fn hub_url(hub: &str, path: impl AsRef<str>) -> String {
format!(
"{}/v1/{}",
hub.trim_end_matches('/'),
path.as_ref().trim_start_matches('/')
)
}
/// Detected tags this host can advertise: pilots on PATH, plus os/arch.
async fn detect_auto_tags() -> Vec<String> {
PilotCaps::detect().await.auto_tags()
}
async fn cmd_enroll(
hub: &str,
enrollment_code: &str,
name: Option<String>,
tags: Vec<String>,
) -> Result<()> {
let _file_guard = lock_config_file(&config_path())?;
let client = http_client();
let nm = match name {
Some(n) if !n.is_empty() => n,
_ => default_name().await,
};
let auto = detect_auto_tags().await;
let e = enroll(&client, hub, enrollment_code, &nm, &tags, &auto).await?;
save_entry_unlocked(
&e.id,
&RoverEntry {
hub: hub.to_string(),
token: e.token.clone(),
name: e.name.clone(),
tags,
},
)?;
logline!("enrolled rover '{}' ({}) on {hub}", e.name, e.id);
Ok(())
}
fn cmd_list() -> Result<()> {
let cfg = load_config()?;
if cfg.rovers.is_empty() {
println!("no rover enrollments — run `ufo rover enroll`");
return Ok(());
}
for (rover_id, e) in &cfg.rovers {
let tags = if e.tags.is_empty() {
String::new()
} else {
format!(" [{}]", e.tags.join(", "))
};
println!("{rover_id} {} {}{tags}", e.name, e.hub);
}
Ok(())
}
async fn cmd_status(rover: Option<String>) -> Result<()> {
let cfg = load_config()?;
let selected: Vec<(String, RoverEntry)> = match rover {
Some(key) => {
let rover_id = resolve_rover_id(&cfg, &key)?;
let e = cfg.rovers.get(&rover_id).expect("resolved").clone();
vec![(rover_id, e)]
}
None => cfg.rovers.into_iter().collect(),
};
if selected.is_empty() {
println!("no rover enrollments — run `ufo rover enroll`");
return Ok(());
}
let client = http_client();
let auto_tags = detect_auto_tags().await;
for (rover_id, mut e) in selected {
let status = match refresh_auto_tags(&client, &e.hub, &e.token, None, &auto_tags).await {
Ok(name) => match name {
Some(name) => sync_rover_name(&rover_id, &mut e, &name)
.map(|_| "ok".to_string())
.unwrap_or_else(|err| format!("sync error: {err:#}")),
None => "ok".to_string(),
},
Err(err) => format!("error: {err:#}"),
};
let tags = if e.tags.is_empty() {
"-".to_string()
} else {
e.tags.join(",")
};
println!(
"{rover_id} {} {} {status} user-tags={tags} auto-tags={}",
e.name,
e.hub,
auto_tags.join(",")
);
}
Ok(())
}
async fn cmd_remove(rover: Option<String>, all: bool) -> Result<()> {
let removed = {
let _guard = CONFIG_LOCK
.lock()
.map_err(|_| anyhow!("rover config lock poisoned"))?;
let _file_guard = lock_config_file(&config_path())?;
let mut cfg = load_config()?;
let targets: Vec<String> = if all {
cfg.rovers.keys().cloned().collect()
} else {
match rover {
Some(u) => vec![resolve_rover_id(&cfg, &u)?],
None => {
return Err(anyhow!(
"pass a rover id/prefix (see `rover list`) or --all"
));
}
}
};
let removed: Vec<(String, RoverEntry)> = targets
.into_iter()
.filter_map(|u| cfg.rovers.remove(&u).map(|e| (u, e)))
.collect();
if !removed.is_empty() {
write_config(&cfg)?;
}
removed
};
if removed.is_empty() {
println!("no rover enrollments");
return Ok(());
}
let client = http_client();
for (u, e) in removed {
// Best-effort hub-side enrollment removal (ignore if unreachable / already gone).
let _ = client
.delete(hub_url(&e.hub, "rover/me"))
.bearer_auth(&e.token)
.send()
.await;
logline!("removed rover '{}' ({u})", e.name);
}
Ok(())
}
async fn cmd_start(
rover: Option<String>,
retry_seconds: u64,
units: usize,
outpost: Option<PathBuf>,
) -> Result<()> {
let cfg = load_config()?;
let selected: Vec<(String, RoverEntry)> = match rover {
Some(key) => {
let rover_id = resolve_rover_id(&cfg, &key)?;
let e = cfg.rovers.get(&rover_id).expect("resolved").clone();
vec![(rover_id, e)]
}
None => cfg.rovers.into_iter().collect(),
};
if selected.is_empty() {
return Err(anyhow!("no rover enrollments — run `ufo rover enroll`"));
}
let base = outpost.unwrap_or_else(default_outpost);
fs::create_dir_all(&base)?;
print_banner();
logline!("outpost: {}", base.display());
// One async task per enrollment; a long run on one rover never stalls another.
let mut set = JoinSet::new();
for (rover_id, entry) in selected {
let base = base.clone();
set.spawn(async move {
if let Err(e) = rover_loop(&rover_id, entry, &base, retry_seconds, units).await {
eprintln!("rover {rover_id} loop exited: {e:#}");
}
});
}
while set.join_next().await.is_some() {}
Ok(())
}
/// One rover's claim/execute loop.
async fn rover_loop(
rover_id: &str,
mut entry: RoverEntry,
base: &Path,
retry_seconds: u64,
units: usize,
) -> Result<()> {
let client = http_client();
let hub = entry.hub.clone();
let token = entry.token.clone();
let operation_base = base.join("rovers").join(rover_id).join("operations");
fs::create_dir_all(&operation_base)?;
let sem = Arc::new(Semaphore::new(units));
let mut last_auto_tags = Vec::new();
logline!(
"rover {} ({rover_id}) started — long-polling {hub} (units={units})",
entry.name
);
loop {
// Acquire a slot *before* claiming, so we never hold more work than we can run.
let permit = sem.clone().acquire_owned().await.expect("semaphore");
let caps = PilotCaps::detect().await;
let auto_tags = caps.auto_tags();
if auto_tags != last_auto_tags {
logline!(
"rover {} ({rover_id}) auto-tags: {}",
entry.name,
auto_tags.join(", ")
);
last_auto_tags = auto_tags.clone();
}
if let Ok(Some(name)) =
refresh_auto_tags(&client, &hub, &token, Some(units), &auto_tags).await
&& let Err(err) = sync_rover_name(rover_id, &mut entry, &name)
{
logline!("rover {rover_id} name sync failed: {err:#}");
}
match claim(&client, &hub, &token).await {
Ok(Some(run)) => {
let client = client.clone();
let hub = hub.clone();
let token = token.clone();
let operation_directory =
match operation_directory_path(&operation_base, &run.operation_id) {
Ok(path) => path,
Err(e) => {
eprintln!("[{rover_id}] refusing run {}: {e:#}", run.id);
let _ = append_event(
&client,
&hub,
&token,
&run.id,
"error",
&format!("guardrail blocked operation id: {}", run.operation_id),
)
.await;
let _ = set_state(&client, &hub, &token, &run.id, "blocked").await;
drop(permit);
continue;
}
};
tokio::spawn(async move {
let _permit = permit; // released when this run finishes
run_one(&client, &hub, &token, &run, &operation_directory, caps).await;
});
}
Ok(None) => drop(permit),
Err(e) if e.downcast_ref::<InvalidRoverToken>().is_some() => {
drop(permit);
return Err(anyhow!(
"stored connection token rejected by {hub} — re-enroll this rover"
));
}
Err(e) => {
drop(permit);
eprintln!("[{rover_id}] claim error: {e:#}");
sleep(Duration::from_secs(retry_seconds)).await;
}
}
}
}
fn operation_directory_path(base: &Path, operation_id: &str) -> Result<PathBuf> {
// One path segment, no traversal.
if operation_id.is_empty()
|| operation_id == "."
|| operation_id == ".."
|| !operation_id
.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')
{
return Err(anyhow!("unsafe operation id"));
}
Ok(base.join(operation_id))
}
/// Prepare the operation work tree and execute the run.
async fn run_one(
client: &Client,
hub: &str,
token: &str,
run: &ClaimedRun,
operation_directory: &Path,
caps: PilotCaps,
) {
match ensure_work_directory(operation_directory).await {
Ok(()) => {
logline!(
"claimed run {} (operation {}) in {}",
&run.id,
run.operation_id,
operation_directory.display()
);
if let Err(e) = execute_run(client, hub, token, run, operation_directory, caps).await {
if e.downcast_ref::<RunLeaseLost>().is_some() {
eprintln!("run {} stopped: lease lost", run.id);
} else {
eprintln!("run {} errored: {e:#}", run.id);
let _ =
append_event(client, hub, token, &run.id, "error", &e.to_string()).await;
let _ = set_state(client, hub, token, &run.id, "failed").await;
}
}
}
Err(e) => {
eprintln!("work tree for run {} failed: {e:#}", run.id);
let _ = append_event(
client,
hub,
token,
&run.id,
"error",
&format!("work tree failed: {e:#}"),
)
.await;
let _ = set_state(client, hub, token, &run.id, "failed").await;
}
}
}
/// Ensure an operation's isolated work directory exists and is git-init'd (one seed
/// commit so later diffs are meaningful). Idempotent — reused across the
/// operation's runs.
async fn ensure_work_directory(path: &Path) -> Result<()> {
fs::create_dir_all(path)?;
if !path.join(".git").exists() {
git(path, &["init", "-q", "-b", "main"]).await?;
git(path, &["config", "user.email", "rover@ufo.local"]).await?;
git(path, &["config", "user.name", "UFO Rover"]).await?;
fs::write(
path.join(".ufo-work-directory"),
"UFO operation work directory\n",
)?;
git(path, &["add", "."]).await?;
git(
path,
&["commit", "-q", "-m", "init operation work directory"],
)
.await?;
}
Ok(())
}
/// Run a git command in a directory, returning stdout (errors on non-zero exit).
async fn git(directory: &Path, args: &[&str]) -> Result<String> {
let out = Command::new("git")
.arg("-C")
.arg(directory)
.args(args)
.output()
.await?;
if !out.status.success() {
return Err(anyhow!(
"git {:?} failed: {}",
args,
String::from_utf8_lossy(&out.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&out.stdout).to_string())
}
#[derive(Debug)]
struct InvalidRoverToken;
impl std::fmt::Display for InvalidRoverToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("invalid connection token")
}
}
impl std::error::Error for InvalidRoverToken {}
#[derive(Debug)]
struct RunLeaseLost;
impl std::fmt::Display for RunLeaseLost {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("run lease lost")
}
}
impl std::error::Error for RunLeaseLost {}
/// Claim the oldest queued run, if any.
async fn claim(client: &Client, hub: &str, token: &str) -> Result<Option<ClaimedRun>> {
let resp = client
.post(hub_url(hub, "rover/runs/claim"))
.bearer_auth(token)
.send()
.await?;
match resp.status() {
StatusCode::NO_CONTENT => Ok(None),
StatusCode::OK => Ok(Some(resp.json::<ClaimedRun>().await?)),
StatusCode::UNAUTHORIZED => Err(InvalidRoverToken.into()),
s => Err(anyhow!("claim returned {s}")),
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum PilotOutput {
JsonLines,
PlainText,
}
struct Pilot {
kind: &'static str,
binary: &'static str,
probe_args: &'static [&'static str],
output: PilotOutput,
command: fn(&Path, &ClaimedRun, &str, &Path) -> Command,
}
const PILOTS: &[Pilot] = &[
Pilot {
kind: "claude",
binary: "claude",
probe_args: &[],
output: PilotOutput::JsonLines,
command: claude_command,
},
Pilot {
kind: "codex",
binary: "codex",
probe_args: &[],
output: PilotOutput::JsonLines,
command: codex_command,
},
Pilot {
kind: "antigravity",
binary: "agy",
probe_args: &[],
output: PilotOutput::PlainText,
command: antigravity_command,
},
Pilot {
kind: "cursor",
binary: "cursor-agent",
probe_args: &["--version"],
output: PilotOutput::PlainText,
command: cursor_agent_command,
},
Pilot {
kind: "copilot",
binary: "copilot",
probe_args: &[],
output: PilotOutput::PlainText,
command: copilot_command,
},
Pilot {
kind: "amp",
binary: "amp",
probe_args: &["--help"],
output: PilotOutput::PlainText,
command: amp_command,
},
Pilot {
kind: "opencode",
binary: "opencode",
probe_args: &[],
output: PilotOutput::PlainText,
command: opencode_command,
},
Pilot {
kind: "openclaw",
binary: "openclaw",
probe_args: &[],
output: PilotOutput::PlainText,
command: openclaw_command,
},
Pilot {
kind: "hermes",
binary: "hermes",
probe_args: &[],
output: PilotOutput::PlainText,
command: hermes_command,
},
Pilot {
kind: "pi",
binary: "pi",
probe_args: &[],
output: PilotOutput::PlainText,
command: pi_command,
},
Pilot {
kind: "kimi",
binary: "kimi",
probe_args: &[],
output: PilotOutput::PlainText,
command: kimi_command,
},
Pilot {
kind: "kiro",
binary: "kiro-cli",
probe_args: &["chat", "--help"],
output: PilotOutput::PlainText,
command: kiro_command,
},
];
/// Pilots available on this host.
#[derive(Clone)]
struct PilotCaps {
paths: HashMap<String, PathBuf>,
}
impl PilotCaps {
async fn detect() -> Self {
let mut paths = HashMap::new();
for pilot in PILOTS {
if let Some(path) = resolve_pilot(pilot).await {
paths.insert(pilot.kind.to_string(), path);
}
}
Self { paths }
}
fn auto_tags(&self) -> Vec<String> {
let mut t = vec![
format!("os:{}", std::env::consts::OS),
format!("arch:{}", std::env::consts::ARCH),
];
for pilot in PILOTS {
if self.paths.contains_key(pilot.kind) {
t.push(format!("pilot:{}", pilot.kind));
}
}
t
}
fn path(&self, kind: &str) -> Option<&Path> {
self.paths.get(kind).map(PathBuf::as_path)
}
}
fn find_pilot(kind: &str) -> Option<&'static Pilot> {
PILOTS.iter().find(|pilot| pilot.kind == kind)
}
fn supported_pilots() -> String {
PILOTS
.iter()
.map(|pilot| pilot.kind)
.collect::<Vec<_>>()
.join(", ")
}
fn claude_command(executable: &Path, run: &ClaimedRun, prompt: &str, cwd: &Path) -> Command {
let mut cmd = Command::new(executable);
cmd.arg("-p")
.arg(prompt)
.arg("--output-format")
.arg("stream-json")
.arg("--verbose")
.arg("--permission-mode")
.arg("bypassPermissions");
if !run.session_id.is_empty() {
cmd.arg("--resume").arg(&run.session_id);
}
cmd.current_dir(cwd).stdin(Stdio::null());
cmd
}
fn codex_command(executable: &Path, run: &ClaimedRun, prompt: &str, cwd: &Path) -> Command {
let mut cmd = Command::new(executable);
if run.session_id.is_empty() {
cmd.arg("exec")
.arg("-s")
.arg("danger-full-access")
.arg("--skip-git-repo-check")
.arg("--json")
.arg(prompt);
} else {
cmd.arg("exec")
.arg("resume")
.arg(&run.session_id)
.arg("-c")
.arg("sandbox_mode=danger-full-access")
.arg("--skip-git-repo-check")
.arg("--json")
.arg(prompt);
}
cmd.current_dir(cwd).stdin(Stdio::null());
cmd
}
fn opencode_command(executable: &Path, run: &ClaimedRun, prompt: &str, cwd: &Path) -> Command {
let mut cmd = Command::new(executable);
cmd.arg("run")
.arg("--dangerously-skip-permissions")
.arg("--dir")
.arg(cwd);
if !run.session_id.is_empty() {
cmd.arg("--session").arg(&run.session_id);
}
cmd.arg(prompt).current_dir(cwd).stdin(Stdio::null());
cmd
}
fn openclaw_session_key(run: &ClaimedRun) -> String {
if run.session_id.is_empty() {
format!("ufo-{}", run.operation_id)
} else {
run.session_id.clone()
}
}
fn openclaw_command(executable: &Path, run: &ClaimedRun, prompt: &str, cwd: &Path) -> Command {
let mut cmd = Command::new(executable);
cmd.arg("agent")
.arg("--session-key")
.arg(openclaw_session_key(run))
.arg("--message")
.arg(prompt)
.arg("--local")
.current_dir(cwd)
.stdin(Stdio::null());
cmd
}
fn antigravity_command(executable: &Path, run: &ClaimedRun, prompt: &str, cwd: &Path) -> Command {
let mut cmd = Command::new(executable);
if !run.session_id.is_empty() {
cmd.arg("--conversation").arg(&run.session_id);
}
cmd.arg("-p")
.arg(prompt)
.current_dir(cwd)
.stdin(Stdio::null());
cmd
}
fn copilot_command(executable: &Path, run: &ClaimedRun, prompt: &str, cwd: &Path) -> Command {
let mut cmd = Command::new(executable);
if !run.session_id.is_empty() {
cmd.arg("--session-id").arg(&run.session_id);
}
cmd.arg("-p")
.arg(prompt)
.arg("--yolo")
.current_dir(cwd)
.stdin(Stdio::null());
cmd
}
fn cursor_agent_command(executable: &Path, run: &ClaimedRun, prompt: &str, cwd: &Path) -> Command {
let mut cmd = Command::new(executable);
cmd.arg("--print")
.arg("--output-format")
.arg("text")
.arg("--model")
.arg("auto")
.arg("--force")
.arg("--trust");
if !run.session_id.is_empty() {
cmd.arg("--resume").arg(&run.session_id);
}
cmd.arg(prompt).current_dir(cwd).stdin(Stdio::null());
cmd
}
fn amp_command(executable: &Path, _run: &ClaimedRun, prompt: &str, cwd: &Path) -> Command {
let mut cmd = Command::new(executable);
cmd.arg("--execute")
.arg(prompt)
.current_dir(cwd)
.stdin(Stdio::null());
cmd
}
fn hermes_command(executable: &Path, run: &ClaimedRun, prompt: &str, cwd: &Path) -> Command {
let mut cmd = Command::new(executable);
if !run.session_id.is_empty() {
cmd.arg("--resume").arg(&run.session_id);
}
cmd.arg("-z")
.arg(prompt)
.current_dir(cwd)
.stdin(Stdio::null());
cmd
}
fn pi_command(executable: &Path, run: &ClaimedRun, prompt: &str, cwd: &Path) -> Command {
let mut cmd = Command::new(executable);
if !run.session_id.is_empty() {
cmd.arg("--session-id").arg(&run.session_id);
}
cmd.arg("--approve")
.arg("--print")
.arg(prompt)
.current_dir(cwd)
.stdin(Stdio::null());
cmd
}
fn kimi_command(executable: &Path, run: &ClaimedRun, prompt: &str, cwd: &Path) -> Command {
let mut cmd = Command::new(executable);
if !run.session_id.is_empty() {
cmd.arg("--session").arg(&run.session_id);
}
cmd.arg("--yolo")
.arg("--prompt")
.arg(prompt)
.arg("--output-format")
.arg("text")
.current_dir(cwd)
.stdin(Stdio::null());
cmd
}
fn kiro_command(executable: &Path, run: &ClaimedRun, prompt: &str, cwd: &Path) -> Command {
let mut cmd = Command::new(executable);
cmd.arg("chat")
.arg("--v3")
.arg("--agent")
.arg("Kiro")
.arg("--no-interactive")
.arg("--trust-all-tools")
.arg("--wrap")
.arg("never");
if !run.session_id.is_empty() {
cmd.arg("--resume-id").arg(&run.session_id);
}
cmd.arg(prompt).current_dir(cwd).stdin(Stdio::null());
cmd
}
async fn resolve_pilot(pilot: &Pilot) -> Option<PathBuf> {
let path = resolve_cli(pilot.binary).await?;
if pilot.probe_args.is_empty() {
return Some(path);
}
Command::new(&path)
.args(pilot.probe_args)
.output()
.await
.ok()
.filter(|o| o.status.success())
.map(|_| path)
}
/// Resolve `<name>` to the executable this rover will invoke.
async fn resolve_cli(name: &str) -> Option<PathBuf> {
if let Some(path) = cli_on_path(name) {
return Some(path);
}
Command::new(name)
.arg("--version")
.output()
.await
.ok()
.filter(|o| o.status.success())
.map(|_| PathBuf::from(name))
}
fn cli_on_path(name: &str) -> Option<PathBuf> {
cli_search_directories()
.iter()
.map(|directory| directory.join(name))
.find(|path| is_executable_file(path))
}
fn cli_search_directories() -> Vec<PathBuf> {
let mut directories = Vec::new();
if let Some(path) = std::env::var_os("PATH") {
directories.extend(std::env::split_paths(&path));
}
let mut out = Vec::with_capacity(directories.len());
for directory in directories {
if !out.contains(&directory) {
out.push(directory);
}
}
out
}
#[cfg(unix)]
fn is_executable_file(path: &Path) -> bool {
fs::metadata(path)
.map(|meta| meta.is_file() && meta.permissions().mode() & 0o111 != 0)
.unwrap_or(false)
}
#[cfg(not(unix))]
fn is_executable_file(path: &Path) -> bool {
fs::metadata(path)
.map(|meta| meta.is_file())
.unwrap_or(false)
}
/// Block a run because the pilot's CLI isn't installed on this rover.
async fn block_no_cli(
client: &Client,
hub: &str,
token: &str,
run_id: &str,
name: &str,
) -> Result<()> {
append_event(
client,
hub,
token,
run_id,
"error",
&format!("{name} CLI not available on this rover"),
)
.await?;
set_state(client, hub, token, run_id, "blocked").await?;
logline!("run {run_id} -> blocked (no {name})");
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn log_pilot_start(
client: &Client,
hub: &str,
token: &str,
run_id: &str,
name: &str,
operation_directory: &Path,
prompt: &str,
resume: bool,
) -> Result<()> {
let verb = if resume { "resume" } else { name };
append_event(
client,
hub,
token,
run_id,
"log",
&format!(
"{verb} in {}: {}",
operation_directory.display(),
first_line(prompt)
),
)
.await
}
/// Sentinel a pilot appends when it needs a human to answer before it can
/// continue. The Hub turns this into an "input requested" signal.
const NEEDS_INPUT_SENTINEL: &str = "@@UFO_NEEDS_INPUT@@";
const NEEDS_INPUT_INSTRUCTION: &str = "If you cannot proceed without a decision or information only a human can provide, do not guess: end your reply with a line containing exactly @@UFO_NEEDS_INPUT@@ followed by your question.";
// The pilot may set the operation's status by emitting @@UFO_STATUS:<status>@@.
const STATUS_PREFIX: &str = "@@UFO_STATUS:";
const STATUS_INSTRUCTION: &str = "By default, finishing leaves the operation In Review for a human. To set a different outcome, end your reply with a line containing exactly @@UFO_STATUS:<status>@@ where <status> is one of in_review, done, blocked, cancelled.";
// Captain split protocol.
const SUB_OPERATIONS_SENTINEL: &str = "@@UFO_SUB_OPERATIONS@@";
const SUB_OPERATIONS_INSTRUCTION: &str = "If this operation splits cleanly into independent, non-overlapping pieces that can run in parallel, do the planning/research first, then end your reply with a line containing exactly @@UFO_SUB_OPERATIONS@@ followed by a JSON array of objects {\"title\": string, \"body\": string, \"assignee\": optional pilot kind}. Each piece must touch different files/areas so they merge cleanly. Omit this entirely if the work is not worth splitting.";
/// Parse the JSON array after @@UFO_SUB_OPERATIONS@@.
fn parse_sub_operations(msg: &str) -> Option<serde_json::Value> {
let i = msg.find(SUB_OPERATIONS_SENTINEL)?;
let rest = msg[i + SUB_OPERATIONS_SENTINEL.len()..].trim();
let v: serde_json::Value = serde_json::from_str(rest).ok()?;
v.is_array().then_some(v)
}
/// Remove the @@UFO_SUB_OPERATIONS@@ marker and its trailing JSON from the human message.
fn strip_sub_operations(msg: &str) -> String {
match msg.find(SUB_OPERATIONS_SENTINEL) {
Some(i) => msg[..i].trim().to_string(),
None => msg.to_string(),
}
}
/// Extract a pilot-requested status from @@UFO_STATUS:x@@, if present.
fn parse_status(msg: &str) -> Option<String> {
let i = msg.find(STATUS_PREFIX)?;
let rest = &msg[i + STATUS_PREFIX.len()..];
let end = rest
.find("@@")
.or_else(|| rest.find('\n'))
.unwrap_or(rest.len());
let status = rest[..end].trim().trim_end_matches('\r');
matches!(status, "in_review" | "done" | "blocked" | "cancelled").then(|| status.to_string())
}
/// Remove a @@UFO_STATUS:x@@ marker from the message the human sees.
fn strip_status(msg: &str) -> String {
let Some(i) = msg.find(STATUS_PREFIX) else {
return msg.to_string();
};
let rest = &msg[i + STATUS_PREFIX.len()..];
let end = rest
.find("@@")
.or_else(|| rest.find('\n'))
.unwrap_or(rest.len());
let status = rest[..end].trim().trim_end_matches('\r');
if !matches!(status, "in_review" | "done" | "blocked" | "cancelled") {
return msg.to_string();
}
let marker_end =
i + STATUS_PREFIX.len() + end + rest[end..].strip_prefix("@@").map_or(0, |_| 2);
format!("{}{}", &msg[..i], &msg[marker_end..])
.trim()
.to_string()
}
/// Run a claimed operation with its assigned pilot, then capture a diff.
async fn execute_run(
client: &Client,
hub: &str,
token: &str,
run: &ClaimedRun,
operation_directory: &Path,
caps: PilotCaps,
) -> Result<()> {
let pilot = run.pilot.as_str();
// The operation title + body, with the needs-input + status protocols appended.
let prompt = if run.prompt.trim().is_empty() {
"Describe this repository in a new file SUMMARY.md.".to_string()
} else {
run.prompt.clone()
};
let mut cli_prompt = format!("{prompt}\n\n{NEEDS_INPUT_INSTRUCTION}\n{STATUS_INSTRUCTION}");
if run.can_propose_sub_operations {
cli_prompt.push('\n');
cli_prompt.push_str(SUB_OPERATIONS_INSTRUCTION);
}
let resume = !run.session_id.is_empty();
let Some(pilot) = find_pilot(pilot) else {
append_event(
client,
hub,
token,
&run.id,
"error",
&format!(
"unsupported pilot '{pilot}' (supported: {})",
supported_pilots()
),
)
.await?;
set_state(client, hub, token, &run.id, "blocked").await?;
return Ok(());
};
let Some(executable) = caps.path(pilot.kind) else {
return block_no_cli(client, hub, token, &run.id, pilot.binary).await;
};
log_pilot_start(
client,
hub,
token,
&run.id,
pilot.kind,
operation_directory,
&prompt,
resume,
)
.await?;
let mut cmd = (pilot.command)(executable, run, &cli_prompt, operation_directory);
set_state(client, hub, token, &run.id, "running").await?;
let pilot_run = async {
match pilot.output {
PilotOutput::JsonLines => {
run_streaming_json(client, hub, token, &run.id, &mut cmd).await
}
PilotOutput::PlainText => {
let (status, message) =
run_streaming(client, hub, token, &run.id, &mut cmd).await?;
let session = if pilot.kind == "openclaw" {
openclaw_session_key(run)
} else {
String::new()
};
Ok((status, session, message))
}
}
};
tokio::pin!(pilot_run);
let lease = heartbeat(client, hub, token, &run.id);
tokio::pin!(lease);
let (status, session, mut message) = tokio::select! {
result = &mut pilot_run => result?,
result = &mut lease => {
result?;
return Err(anyhow!("heartbeat stopped unexpectedly"));
}
};
// Sentinels are protocol markers, not part of the human-visible comment.
let needs_input = message.contains(NEEDS_INPUT_SENTINEL);
if needs_input {
message = message.replace(NEEDS_INPUT_SENTINEL, "").trim().to_string();
}
let operation_status = parse_status(&message).unwrap_or_default();
if !operation_status.is_empty() {
message = strip_status(&message);
}
let sub_operations = parse_sub_operations(&message);
if sub_operations.is_some() {
message = strip_sub_operations(&message);
}
// Capture the working-tree diff (include untracked via intent-to-add).
let _ = git(operation_directory, &["add", "-N", "."]).await;
let diff = git(operation_directory, &["diff"])
.await
.unwrap_or_default();
let content = if diff.trim().is_empty() {
"(no changes)".to_string()
} else {
diff
};
upload_artifact(client, hub, token, &run.id, "diff", "git.diff", &content).await?;
// Record the session + post the pilot's final message as a comment.
post_result(
client,
hub,
token,
&run.id,
&session,
&message,
needs_input,
&operation_status,
sub_operations.as_ref(),
)
.await?;
let final_state = if status.success() {
"succeeded"
} else {
"failed"
};
append_event(
client,
hub,
token,
&run.id,
"result",
&format!("pilot exited with {:?}", status.code()),
)
.await?;
set_state(client, hub, token, &run.id, final_state).await?;
logline!("run {} -> {}", &run.id, final_state);
Ok(())
}
fn first_line(s: &str) -> &str {
s.lines().next().unwrap_or("")
}
/// Renew the run's lease (UFO_ROVER_HEARTBEAT_SECONDS, default 5), returning if the hub says ownership was lost.
async fn heartbeat(client: &Client, hub: &str, token: &str, run_id: &str) -> Result<()> {
let interval = std::env::var("UFO_ROVER_HEARTBEAT_SECONDS")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|&n| n > 0)
.unwrap_or(5);
loop {
sleep(Duration::from_secs(interval)).await;
match client
.put(hub_url(hub, format!("rover/runs/{run_id}/heartbeat")))
.bearer_auth(token)
.send()
.await
{
Ok(resp) if resp.status().is_success() => {}
Ok(resp)
if matches!(
resp.status(),
StatusCode::NOT_FOUND | StatusCode::UNAUTHORIZED
) =>
{
return Err(RunLeaseLost.into());
}
Ok(resp) => logline!("heartbeat run {run_id} -> {}", resp.status()),
Err(e) => logline!("heartbeat run {run_id} failed: {e}"),
}
}
}
/// Spawn the command (stdout+stderr piped), stream both as log events, and wait.
async fn run_streaming(
client: &Client,
hub: &str,
token: &str,
run_id: &str,
cmd: &mut Command,
) -> Result<(std::process::ExitStatus, String)> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
cmd.kill_on_drop(true);
let mut child = cmd.spawn()?;
let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
let stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
// Drain stderr concurrently so a full pipe can't deadlock the child.
let err_task = {
let client = client.clone();
let hub = hub.to_string();
let token = token.to_string();
let run_id = run_id.to_string();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = client
.post(hub_url(&hub, format!("rover/runs/{run_id}/events")))
.bearer_auth(&token)
.json(&json!({ "kind": "log", "message": line }))
.send()
.await;
}
})
};
// Shell output is the transcript: stream each stdout line as a text message.
let mut sequence: i64 = 0;
let mut message = String::new();
let mut lines = BufReader::new(stdout).lines();
while let Some(line) = lines.next_line().await? {
logline!("[run {run_id}] {line}");
if !message.is_empty() {
message.push('\n');
}
message.push_str(&line);
post_message(
client,
hub,
token,
run_id,
&mut sequence,
"text",
None,
Some(&line),
None,
None,
)
.await?;
}
let status = child.wait().await?;
let _ = err_task.await;
Ok((status, message))
}
/// Like run_streaming but parses structured JSONL pilot output:
/// streams readable lines as log events and returns (status, session_id, message).
async fn run_streaming_json(
client: &Client,
hub: &str,
token: &str,
run_id: &str,
cmd: &mut Command,
) -> Result<(std::process::ExitStatus, String, String)> {
cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
cmd.kill_on_drop(true);
let mut child = cmd.spawn()?;
let stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
let stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
let err_task = {
let client = client.clone();
let hub = hub.to_string();
let token = token.to_string();
let run_id = run_id.to_string();
tokio::spawn(async move {
let mut lines = BufReader::new(stderr).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = client
.post(hub_url(&hub, format!("rover/runs/{run_id}/events")))
.bearer_auth(&token)
.json(&json!({ "kind": "log", "message": line }))
.send()
.await;
}
})
};
let mut session = String::new();
let mut message = String::new();
let mut sequence: i64 = 0;
let mut lines = BufReader::new(stdout).lines();
while let Some(line) = lines.next_line().await? {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let v: serde_json::Value = match serde_json::from_str(trimmed) {
Ok(v) => v,
Err(_) => {
post_message(
client,
hub,
token,
run_id,
&mut sequence,
"text",
None,
Some(trimmed),
None,
None,
)
.await?;
continue;
}
};
if let Some(s) = v.get("session_id").and_then(|x| x.as_str())
&& !s.is_empty()
{
session = s.to_string();
}
if v.get("type").and_then(|x| x.as_str()) == Some("thread.started")
&& let Some(s) = v.get("thread_id").and_then(|x| x.as_str())
{
session = s.to_string();
}
process_event(client, hub, token, run_id, &mut sequence, &v, &mut message).await?;
}
let status = child.wait().await?;
let _ = err_task.await;
Ok((status, session, message))
}
/// Translate one structured JSON pilot event into transcript messages.
async fn process_event(
client: &Client,
hub: &str,
token: &str,
run_id: &str,
sequence: &mut i64,
v: &serde_json::Value,
message: &mut String,
) -> Result<()> {
match v.get("type").and_then(|x| x.as_str()) {
// claude: an assistant turn carries text / thinking / tool_use blocks.
Some("assistant") => {
if let Some(content) = v
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
{
for item in content {
match item.get("type").and_then(|x| x.as_str()) {
Some("text") => {
if let Some(t) = item.get("text").and_then(|x| x.as_str())
&& !t.trim().is_empty()
{
post_message(
client,
hub,
token,
run_id,
sequence,
"text",
None,
Some(t),
None,
None,
)
.await?;
}
}
Some("thinking") => {
if let Some(t) = item.get("thinking").and_then(|x| x.as_str()) {
post_message(
client,
hub,
token,
run_id,
sequence,
"thinking",
None,
Some(t),
None,
None,
)
.await?;
}
}
Some("tool_use") => {
let name = item.get("name").and_then(|x| x.as_str()).unwrap_or("tool");
post_message(
client,
hub,
token,
run_id,
sequence,
"tool_use",
Some(name),
None,
item.get("input").cloned(),
None,
)
.await?;
}
_ => {}
}
}
}
}
// claude: tool results come back as a user message.
Some("user") => {
if let Some(content) = v
.get("message")
.and_then(|m| m.get("content"))
.and_then(|c| c.as_array())
{
for item in content {
if item.get("type").and_then(|x| x.as_str()) == Some("tool_result") {
let out = tool_result_text(item.get("content"));
post_message(
client,
hub,
token,
run_id,
sequence,
"tool_result",
None,
None,
None,
Some(&out),
)
.await?;
}
}
}
}
Some("result") => {
if let Some(t) = v.get("result").and_then(|x| x.as_str()) {
*message = t.to_string();
}
}
// codex: each item.completed is a step.
Some("item.completed") => {
if let Some(item) = v.get("item") {
match item.get("type").and_then(|x| x.as_str()) {
Some("agent_message") => {
if let Some(t) = item.get("text").and_then(|x| x.as_str()) {
*message = t.to_string();
post_message(
client,
hub,
token,
run_id,
sequence,
"text",
None,
Some(t),
None,
None,
)
.await?;
}
}
Some("reasoning") => {
if let Some(t) = item.get("text").and_then(|x| x.as_str()) {
post_message(
client,
hub,
token,
run_id,
sequence,
"thinking",
None,
Some(t),
None,
None,
)
.await?;
}
}
Some("command_execution") => {
let cmd = item.get("command").and_then(|x| x.as_str()).unwrap_or("");
post_message(
client,
hub,
token,
run_id,
sequence,
"tool_use",
Some("shell"),
None,
Some(json!({ "command": cmd })),
None,
)
.await?;
if let Some(out) = item
.get("aggregated_output")
.or_else(|| item.get("output"))
.and_then(|x| x.as_str())
&& !out.trim().is_empty()
{
post_message(
client,
hub,
token,
run_id,
sequence,
"tool_result",
Some("shell"),
None,
None,
Some(out),
)
.await?;
}
}
Some("file_change") => {
post_message(
client,
hub,
token,
run_id,
sequence,
"tool_use",
Some("edit"),
None,
item.get("changes").cloned(),
None,
)
.await?;
}
_ => {}
}
}
}
_ => {}
}
Ok(())
}
/// Claude tool_result content is either a string or an array of text blocks.
fn tool_result_text(content: Option<&serde_json::Value>) -> String {
match content {
Some(serde_json::Value::String(s)) => s.clone(),
Some(serde_json::Value::Array(arr)) => arr
.iter()
.filter_map(|b| b.get("text").and_then(|x| x.as_str()))
.collect::<Vec<_>>()
.join("\n"),
_ => String::new(),
}
}
/// Post one typed transcript message and advance the per-run sequence.
#[allow(clippy::too_many_arguments)]
async fn post_message(
client: &Client,
hub: &str,
token: &str,
run_id: &str,
sequence: &mut i64,
mtype: &str,
tool: Option<&str>,
content: Option<&str>,
input: Option<serde_json::Value>,
output: Option<&str>,
) -> Result<()> {
let mut body = serde_json::Map::new();
body.insert("sequence".into(), json!(*sequence));
body.insert("type".into(), json!(mtype));
if let Some(t) = tool {
body.insert("tool".into(), json!(t));
}
if let Some(c) = content {
body.insert("content".into(), json!(c));
}
if let Some(i) = input {
body.insert("input".into(), i);
}
if let Some(o) = output {
body.insert("output".into(), json!(o));
}
*sequence += 1;
let resp = client
.post(hub_url(hub, format!("rover/runs/{run_id}/messages")))
.bearer_auth(token)
.json(&serde_json::Value::Object(body))
.send()
.await?;
if matches!(
resp.status(),
StatusCode::NOT_FOUND | StatusCode::UNAUTHORIZED
) {
return Err(RunLeaseLost.into());
}
if !resp.status().is_success() {
logline!("message post run {run_id} -> {}", resp.status());
}
Ok(())
}
#[allow(clippy::too_many_arguments)]
async fn post_result(
client: &Client,
hub: &str,
token: &str,
run_id: &str,
session: &str,
message: &str,
needs_input: bool,
operation_status: &str,
sub_operations: Option<&serde_json::Value>,
) -> Result<()> {
let mut body = json!({ "session_id": session, "message": message, "needs_input": needs_input, "operation_status": operation_status });
if let Some(s) = sub_operations {
body["sub_operations"] = s.clone();
}
let resp = client
.post(hub_url(hub, format!("rover/runs/{run_id}/result")))
.bearer_auth(token)
.json(&body)
.send()
.await?;
if matches!(
resp.status(),
StatusCode::NOT_FOUND | StatusCode::UNAUTHORIZED
) {
return Err(RunLeaseLost.into());
}
if !resp.status().is_success() {
return Err(anyhow!("result post returned {}", resp.status()));
}
Ok(())
}
async fn set_state(
client: &Client,
hub: &str,
token: &str,
run_id: &str,
state: &str,
) -> Result<()> {
let resp = client
.patch(hub_url(hub, format!("rover/runs/{run_id}")))
.bearer_auth(token)
.json(&json!({ "state": state }))
.send()
.await?;
if !resp.status().is_success() {
return Err(anyhow!("set_state {state} returned {}", resp.status()));
}
Ok(())
}
async fn append_event(
client: &Client,
hub: &str,
token: &str,
run_id: &str,
kind: &str,
message: &str,
) -> Result<()> {
let resp = client
.post(hub_url(hub, format!("rover/runs/{run_id}/events")))
.bearer_auth(token)
.json(&json!({ "kind": kind, "message": message }))
.send()
.await?;
if !resp.status().is_success() {
return Err(anyhow!("append_event returned {}", resp.status()));
}
Ok(())
}
async fn upload_artifact(
client: &Client,
hub: &str,
token: &str,
run_id: &str,
kind: &str,
name: &str,
content: &str,
) -> Result<()> {
let resp = client
.post(hub_url(hub, format!("rover/runs/{run_id}/artifacts")))
.bearer_auth(token)
.json(&json!({ "kind": kind, "name": name, "content": content }))
.send()
.await?;
if !resp.status().is_success() {
return Err(anyhow!("upload_artifact returned {}", resp.status()));
}
Ok(())
}
// ---- enrollments + local store (~/.ufo/rovers.json), keyed by rover id ----
#[derive(Serialize, Deserialize, Clone)]
struct RoverEntry {
hub: String,
token: String,
name: String,
#[serde(default)]
tags: Vec<String>, // user tags (auto-tags live hub-side)
}
#[derive(Serialize, Deserialize, Default)]
struct RoverConfig {
#[serde(default)]
rovers: HashMap<String, RoverEntry>, // key = hub-assigned rover id
}
#[derive(Debug, Deserialize)]
struct EnrollResp {
token: String,
id: String,
name: String,
}
#[derive(Debug, Deserialize)]
struct RoverRefreshResp {
name: String,
}
fn config_path() -> PathBuf {
if let Ok(path) = std::env::var("UFO_ROVER_CONFIG") {
return PathBuf::from(path);
}
let home = std::env::var("HOME").unwrap_or_else(|_| ".".to_string());
PathBuf::from(home).join(".ufo").join("rovers.json")
}
struct ConfigFileLock {
path: PathBuf,
_file: fs::File,
}
impl Drop for ConfigFileLock {
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}
fn config_lock_path(path: &Path) -> PathBuf {
let mut lock = path.to_path_buf();
let mut name = path
.file_name()
.map(|s| s.to_os_string())
.unwrap_or_else(|| "rovers.json".into());
name.push(".lock");
lock.set_file_name(name);
lock
}
fn lock_config_file(path: &Path) -> Result<ConfigFileLock> {
if let Some(directory) = path.parent() {
fs::create_dir_all(directory)?;
}
let lock_path = config_lock_path(path);
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.open(&lock_path)
.with_context(|| {
format!(
"rover config is locked by another process: {}",
lock_path.display()
)
})?;
writeln!(file, "{}", std::process::id())?;
Ok(ConfigFileLock {
path: lock_path,
_file: file,
})
}
fn load_config() -> Result<RoverConfig> {
match fs::read_to_string(config_path()) {
Ok(s) => Ok(serde_json::from_str(&s).context("parse rover config")?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(RoverConfig::default()),
Err(e) => Err(e).context("read rover config"),
}
}
/// Resolve a rover id or unambiguous prefix to a full rover id in the config.
fn resolve_rover_id(cfg: &RoverConfig, key: &str) -> Result<String> {
if cfg.rovers.contains_key(key) {
return Ok(key.to_string());
}
let matches: Vec<&String> = cfg.rovers.keys().filter(|k| k.starts_with(key)).collect();
match matches.len() {
1 => Ok(matches[0].clone()),
0 => Err(anyhow!("no rover matching '{key}' — run `ufo rover list`")),
_ => Err(anyhow!(
"'{key}' is ambiguous ({} rovers match) — use more characters",
matches.len()
)),
}
}
fn write_config(cfg: &RoverConfig) -> Result<()> {
let path = config_path();
if let Some(directory) = path.parent() {
fs::create_dir_all(directory)?;
}
let tmp = path.with_extension("json.tmp");
let _ = fs::remove_file(&tmp);
write_private_file(&tmp, &serde_json::to_vec_pretty(cfg)?)?;
set_config_permissions(&tmp)?;
fs::rename(tmp, path)?;
Ok(())
}
#[cfg(unix)]
fn write_private_file(path: &Path, data: &[u8]) -> Result<()> {
let mut file = OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)?;
file.write_all(data)?;
Ok(())
}
#[cfg(not(unix))]
fn write_private_file(path: &Path, data: &[u8]) -> Result<()> {
fs::write(path, data)?;
Ok(())
}
#[cfg(unix)]
fn set_config_permissions(path: &Path) -> Result<()> {
fs::set_permissions(path, fs::Permissions::from_mode(0o600))?;
Ok(())
}
#[cfg(not(unix))]
fn set_config_permissions(_path: &Path) -> Result<()> {
Ok(())
}
fn save_entry(rover_id: &str, entry: &RoverEntry) -> Result<()> {
let _guard = CONFIG_LOCK
.lock()
.map_err(|_| anyhow!("rover config lock poisoned"))?;
let _file_guard = lock_config_file(&config_path())?;
save_entry_unlocked(rover_id, entry)
}
fn save_entry_unlocked(rover_id: &str, entry: &RoverEntry) -> Result<()> {
let mut cfg = load_config()?;
cfg.rovers.insert(rover_id.to_string(), entry.clone());
write_config(&cfg)
}
fn sync_rover_name(rover_id: &str, entry: &mut RoverEntry, name: &str) -> Result<()> {
let name = name.trim();
if name.is_empty() || name == entry.name {
return Ok(());
}
entry.name = name.to_string();
save_entry(rover_id, entry)
}
async fn default_name() -> String {
let mut host = "rover".to_string();
if let Ok(out) = Command::new("hostname").output().await {
let h = String::from_utf8_lossy(&out.stdout).trim().to_string();
if !h.is_empty() {
host = h;
}
}
// Suffix to distinguish multiple enrollments on one host.
let suffix = format!(
"{:05x}",
chrono::Local::now().timestamp_subsec_nanos() & 0xfffff
);
format!("{host}-{suffix}")
}
/// Refresh the rover's hub-side auto-tags (idempotent; leaves user tags alone).
async fn refresh_auto_tags(
client: &Client,
hub: &str,
token: &str,
units: Option<usize>,
tags: &[String],
) -> Result<Option<String>> {
let mut body = serde_json::Map::new();
body.insert("auto_tags".to_string(), json!(tags));
if let Some(units) = units {
body.insert("units".to_string(), json!(units));
}
let resp = client
.patch(hub_url(hub, "rover/me"))
.bearer_auth(token)
.json(&body)
.send()
.await?;
match resp.status() {
StatusCode::NO_CONTENT => Ok(None),
s if s.is_success() => Ok(Some(resp.json::<RoverRefreshResp>().await?.name)),
StatusCode::UNAUTHORIZED => Err(InvalidRoverToken.into()),
s => Err(anyhow!("tag refresh returned {s}")),
}
}
/// Exchange an enrollment code for a per-rover connection token (advertising tags).
async fn enroll(
client: &Client,
hub: &str,
enrollment_code: &str,
name: &str,
tags: &[String],
auto_tags: &[String],
) -> Result<EnrollResp> {
let resp = client
.post(hub_url(hub, "rover/enroll"))
.bearer_auth(enrollment_code)
.json(&json!({ "name": name, "tags": tags, "auto_tags": auto_tags }))
.send()
.await?;
if !resp.status().is_success() {
return Err(anyhow!("enroll returned {}", resp.status()));
}
Ok(resp.json().await?)
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn make_executable(path: &Path) -> Result<()> {
#[cfg(unix)]
{
fs::set_permissions(path, fs::Permissions::from_mode(0o755))?;
}
Ok(())
}
fn set_env(key: &str, value: impl AsRef<std::ffi::OsStr>) {
// SAFETY: env-mutating tests hold ENV_LOCK, so this test module never
// mutates or reads these variables concurrently.
unsafe { std::env::set_var(key, value) }
}
fn remove_env(key: &str) {
// SAFETY: env-mutating tests hold ENV_LOCK, so this test module never
// mutates or reads these variables concurrently.
unsafe { std::env::remove_var(key) }
}
fn sample_entry(name: &str) -> RoverEntry {
RoverEntry {
hub: "http://localhost:8080".to_string(),
token: format!("{name}-token"),
name: name.to_string(),
tags: vec!["gpu".to_string(), "region:moon".to_string()],
}
}
#[test]
fn status_sentinel_is_parsed_and_stripped() {
let msg = "Ready for review.\n@@UFO_STATUS:done@@";
assert_eq!(parse_status(msg).as_deref(), Some("done"));
assert_eq!(strip_status(msg), "Ready for review.");
}
#[test]
fn hub_url_appends_version() {
assert_eq!(
hub_url("http://h:8080", "rover/runs/claim"),
"http://h:8080/v1/rover/runs/claim"
);
assert_eq!(
hub_url("http://h:8080/", "/rover/me"),
"http://h:8080/v1/rover/me"
);
}
#[test]
fn sub_operations_sentinel_is_parsed_and_stripped() {
let msg = "Planned it.\n@@UFO_SUB_OPERATIONS@@\n[{\"title\":\"A\"},{\"title\":\"B\"}]";
let sub_operations = parse_sub_operations(msg).expect("sub-operations parse");
assert_eq!(sub_operations.as_array().unwrap().len(), 2);
assert_eq!(strip_sub_operations(msg), "Planned it.");
assert!(parse_sub_operations("just a reply").is_none());
assert!(parse_sub_operations("x\n@@UFO_SUB_OPERATIONS@@\nnot json").is_none());
}
#[test]
fn status_sentinel_without_closer_is_parsed_and_stripped() {
let msg = "Almost there.\n@@UFO_STATUS:done";
assert_eq!(parse_status(msg).as_deref(), Some("done"));
assert_eq!(strip_status(msg), "Almost there.");
}
#[test]
fn tool_result_text_accepts_string_and_text_blocks() {
assert_eq!(
tool_result_text(Some(&json!("plain output"))),
"plain output"
);
assert_eq!(
tool_result_text(Some(&json!([
{ "type": "text", "text": "first" },
{ "type": "image", "data": "ignored" },
{ "type": "text", "text": "second" }
]))),
"first\nsecond"
);
}
#[test]
fn resolve_rover_id_requires_unambiguous_prefix() {
let mut cfg = RoverConfig::default();
cfg.rovers.insert("abc111".to_string(), sample_entry("one"));
cfg.rovers.insert("abc222".to_string(), sample_entry("two"));
cfg.rovers
.insert("def333".to_string(), sample_entry("three"));
assert_eq!(resolve_rover_id(&cfg, "def").unwrap(), "def333");
assert!(resolve_rover_id(&cfg, "abc").is_err());
assert!(resolve_rover_id(&cfg, "zzz").is_err());
}
#[test]
fn operation_directory_rejects_path_traversal() {
let base = std::env::temp_dir().join("ufo-operations");
assert_eq!(
operation_directory_path(&base, "123e4567-e89b-12d3-a456-426614174000").unwrap(),
base.join("123e4567-e89b-12d3-a456-426614174000")
);
assert!(operation_directory_path(&base, "../escape").is_err());
assert!(operation_directory_path(&base, "nested/operation").is_err());
assert!(operation_directory_path(&base, "").is_err());
}
#[test]
fn cli_detection_uses_only_executables_on_path() {
let _guard = ENV_LOCK.lock().unwrap();
let base = std::env::temp_dir().join(format!(
"ufo-rover-path-test-{}",
chrono::Local::now()
.timestamp_nanos_opt()
.unwrap_or_default()
));
fs::create_dir_all(&base).unwrap();
let old_path = std::env::var_os("PATH");
let old_home = std::env::var_os("HOME");
let fake = base.join("fake-pilot");
let home_bin = base.join("home-bin");
let home_fake = home_bin.join("home-pilot");
let result = (|| -> Result<()> {
fs::create_dir_all(&home_bin)?;
fs::write(&fake, "x")?;
fs::write(&home_fake, "x")?;
make_executable(&fake)?;
make_executable(&home_fake)?;
set_env("PATH", &base);
set_env("HOME", &base);
assert_eq!(cli_on_path("fake-pilot").as_deref(), Some(fake.as_path()));
assert!(cli_on_path("home-pilot").is_none());
assert!(cli_on_path("missing-pilot").is_none());
Ok(())
})();
match old_path {
Some(path) => set_env("PATH", path),
None => remove_env("PATH"),
}
match old_home {
Some(home) => set_env("HOME", home),
None => remove_env("HOME"),
}
let _ = fs::remove_dir_all(base);
result.unwrap();
}
#[test]
fn cli_detection_preserves_path_precedence() {
let _guard = ENV_LOCK.lock().unwrap();
let base = std::env::temp_dir().join(format!(
"ufo-rover-path-order-test-{}",
chrono::Local::now()
.timestamp_nanos_opt()
.unwrap_or_default()
));
let first = base.join("first");
let second = base.join("second");
fs::create_dir_all(&first).unwrap();
fs::create_dir_all(&second).unwrap();
let old_path = std::env::var_os("PATH");
let result = (|| -> Result<()> {
for path in [first.join("pilot"), second.join("pilot")] {
fs::write(&path, "x")?;
make_executable(&path)?;
}
let joined = std::env::join_paths([second.as_path(), first.as_path()])?;
set_env("PATH", joined);
let expected = second.join("pilot");
assert_eq!(cli_on_path("pilot").as_deref(), Some(expected.as_path()));
Ok(())
})();
match old_path {
Some(path) => set_env("PATH", path),
None => remove_env("PATH"),
}
let _ = fs::remove_dir_all(base);
result.unwrap();
}
#[test]
fn pilot_caps_emit_auto_tags_from_resolved_clis() {
let caps = PilotCaps {
paths: HashMap::from([("claude".to_string(), std::env::temp_dir().join("claude"))]),
};
assert_eq!(
caps.auto_tags(),
vec![
format!("os:{}", std::env::consts::OS),
format!("arch:{}", std::env::consts::ARCH),
"pilot:claude".to_string(),
]
);
}
#[test]
fn pilot_registry_includes_default_pilots() {
for kind in [
"claude",
"codex",
"antigravity",
"cursor",
"copilot",
"amp",
"opencode",
"openclaw",
"hermes",
"pi",
"kimi",
"kiro",
] {
assert!(find_pilot(kind).is_some(), "{kind} pilot missing");
}
assert!(find_pilot("missing").is_none());
}
#[test]
fn antigravity_command_uses_print_mode() {
let run = ClaimedRun {
id: "run".to_string(),
operation_id: "operation".to_string(),
state: "queued".to_string(),
pilot: "antigravity".to_string(),
prompt: String::new(),
session_id: "session".to_string(),
can_propose_sub_operations: false,
};
let cmd = antigravity_command(Path::new("agy"), &run, "do it", Path::new("/tmp"));
let args = cmd
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(args, vec!["--conversation", "session", "-p", "do it"]);
}
#[test]
fn copilot_command_uses_prompt_mode() {
let run = ClaimedRun {
id: "run".to_string(),
operation_id: "operation".to_string(),
state: "queued".to_string(),
pilot: "copilot".to_string(),
prompt: String::new(),
session_id: "session".to_string(),
can_propose_sub_operations: false,
};
let cmd = copilot_command(Path::new("copilot"), &run, "do it", Path::new("/tmp"));
let args = cmd
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(
args,
vec!["--session-id", "session", "-p", "do it", "--yolo"]
);
}
#[test]
fn plain_text_pilots_use_non_interactive_prompt_mode() {
let run = ClaimedRun {
id: "run".to_string(),
operation_id: "operation".to_string(),
state: "queued".to_string(),
pilot: String::new(),
prompt: String::new(),
session_id: "session".to_string(),
can_propose_sub_operations: false,
};
for (args, expected) in [
(
hermes_command(Path::new("hermes"), &run, "do it", Path::new("/tmp"))
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
vec!["--resume", "session", "-z", "do it"],
),
(
cursor_agent_command(Path::new("cursor-agent"), &run, "do it", Path::new("/tmp"))
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
vec![
"--print",
"--output-format",
"text",
"--model",
"auto",
"--force",
"--trust",
"--resume",
"session",
"do it",
],
),
(
amp_command(Path::new("amp"), &run, "do it", Path::new("/tmp"))
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
vec!["--execute", "do it"],
),
(
pi_command(Path::new("pi"), &run, "do it", Path::new("/tmp"))
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
vec!["--session-id", "session", "--approve", "--print", "do it"],
),
(
kimi_command(Path::new("kimi"), &run, "do it", Path::new("/tmp"))
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
vec![
"--session",
"session",
"--yolo",
"--prompt",
"do it",
"--output-format",
"text",
],
),
(
kiro_command(Path::new("kiro-cli"), &run, "do it", Path::new("/tmp"))
.as_std()
.get_args()
.map(|arg| arg.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
vec![
"chat",
"--v3",
"--agent",
"Kiro",
"--no-interactive",
"--trust-all-tools",
"--wrap",
"never",
"--resume-id",
"session",
"do it",
],
),
] {
assert_eq!(args, expected);
}
}
#[test]
fn config_roundtrip_preserves_multiple_rover_enrollments() {
let _guard = ENV_LOCK.lock().unwrap();
let base = std::env::temp_dir().join(format!(
"ufo-rover-test-{}",
chrono::Local::now()
.timestamp_nanos_opt()
.unwrap_or_default()
));
let path = base.join("rovers.json");
set_env("UFO_ROVER_CONFIG", &path);
let result = (|| -> Result<()> {
save_entry("rover-a", &sample_entry("alpha"))?;
save_entry("rover-b", &sample_entry("beta"))?;
let cfg = load_config()?;
assert_eq!(cfg.rovers.len(), 2);
assert_eq!(cfg.rovers["rover-a"].token, "alpha-token");
assert_eq!(cfg.rovers["rover-b"].token, "beta-token");
#[cfg(unix)]
{
let mode = fs::metadata(&path)?.permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
Ok(())
})();
remove_env("UFO_ROVER_CONFIG");
let _ = fs::remove_dir_all(base);
result.unwrap();
}
#[test]
fn sync_rover_name_updates_local_config() {
let _guard = ENV_LOCK.lock().unwrap();
let base = std::env::temp_dir().join(format!(
"ufo-rover-rename-test-{}",
chrono::Local::now()
.timestamp_nanos_opt()
.unwrap_or_default()
));
let path = base.join("rovers.json");
set_env("UFO_ROVER_CONFIG", &path);
let result = (|| -> Result<()> {
let mut entry = sample_entry("old");
save_entry("rover-a", &entry)?;
sync_rover_name("rover-a", &mut entry, "new")?;
let cfg = load_config()?;
assert_eq!(entry.name, "new");
assert_eq!(cfg.rovers["rover-a"].name, "new");
Ok(())
})();
remove_env("UFO_ROVER_CONFIG");
let _ = fs::remove_dir_all(base);
result.unwrap();
}
#[test]
fn config_file_lock_rejects_concurrent_writer() {
let _guard = ENV_LOCK.lock().unwrap();
let base = std::env::temp_dir().join(format!(
"ufo-rover-lock-test-{}",
chrono::Local::now()
.timestamp_nanos_opt()
.unwrap_or_default()
));
let path = base.join("rovers.json");
set_env("UFO_ROVER_CONFIG", &path);
let result = (|| -> Result<()> {
let _lock = lock_config_file(&path)?;
let err = save_entry("rover-a", &sample_entry("alpha")).unwrap_err();
assert!(err.to_string().contains("locked by another process"));
Ok(())
})();
remove_env("UFO_ROVER_CONFIG");
let _ = fs::remove_dir_all(base);
result.unwrap();
}
#[test]
fn malformed_config_returns_an_error() {
let _guard = ENV_LOCK.lock().unwrap();
let base = std::env::temp_dir().join(format!(
"ufo-rover-bad-config-{}",
chrono::Local::now()
.timestamp_nanos_opt()
.unwrap_or_default()
));
let path = base.join("rovers.json");
fs::create_dir_all(&base).unwrap();
fs::write(&path, "{not-json").unwrap();
set_env("UFO_ROVER_CONFIG", &path);
let err = match load_config() {
Ok(_) => panic!("malformed config should not be ignored"),
Err(err) => err,
};
remove_env("UFO_ROVER_CONFIG");
let _ = fs::remove_dir_all(base);
assert!(err.to_string().contains("parse rover config"));
}
}