use std::io::{Read as _, Write as _};
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Mutex;
use std::time::{Duration, Instant};
pub(super) struct Launch<'a> {
pub executable: &'a Path,
pub port: u16,
pub data_dir: &'a Path,
pub token_secret: &'a str,
pub log: &'a Path,
pub claude_home: Option<&'a Path>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub(super) enum ClaudeLogin {
Keychain,
File,
Absent,
}
impl ClaudeLogin {
pub(super) const fn as_str(self) -> &'static str {
match self {
Self::Keychain => "keychain",
Self::File => "file",
Self::Absent => "absent",
}
}
}
pub(super) trait HostRuntime {
fn executable(&self) -> Result<PathBuf, String>;
fn spawn(&self, launch: &Launch<'_>) -> Result<u32, String>;
fn serving(&self, pid: u32, executable: &Path) -> bool;
fn terminate(&self, pid: u32) -> Result<(), String>;
fn status(&self, port: u16, path: &str, bearer: Option<&str>) -> Option<u16>;
fn free_port(&self) -> Result<u16, String>;
fn user_id(&self) -> Option<u32>;
fn claude_login(&self) -> ClaudeLogin;
fn token_inventory(&self, executable: &Path, data_dir: &Path) -> Result<String, String>;
fn catalog(
&self,
port: u16,
bearer: &str,
) -> Result<std::collections::BTreeSet<String>, String> {
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|_| "catalog runtime unavailable")?;
runtime.block_on(async {
let client = reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(10))
.build()
.map_err(|_| "catalog client unavailable")?;
let response = client
.get(format!("http://127.0.0.1:{port}/api/models"))
.bearer_auth(bearer)
.send()
.await
.map_err(|_| "issued-token catalog request failed")?;
if !response.status().is_success() {
return Err("issued-token catalog was rejected".into());
}
let body: serde_json::Value =
response.json().await.map_err(|_| "catalog JSON invalid")?;
let rows = body["data"].as_array().ok_or("catalog models absent")?;
rows.iter()
.map(|row| {
let id = row["id"].as_str().ok_or("catalog model ID absent")?;
Ok(format!(
"{}/{id}",
row["owned_by"].as_str().unwrap_or_default()
))
})
.collect()
})
}
}
#[derive(Default)]
pub(super) struct System {
children: Mutex<Vec<Child>>,
}
impl HostRuntime for System {
fn executable(&self) -> Result<PathBuf, String> {
std::env::current_exe()
.and_then(|path| path.canonicalize())
.map_err(|error| format!("could not resolve the Router executable: {error}"))
}
fn spawn(&self, launch: &Launch<'_>) -> Result<u32, String> {
let log = open_log(launch.log)?;
let error_log = log
.try_clone()
.map_err(|error| format!("could not open {}: {error}", launch.log.display()))?;
let mut command = Command::new(launch.executable);
command
.arg("serve")
.current_dir(launch.data_dir)
.env("ROUTER_HOST", "127.0.0.1")
.env("ROUTER_PORT", launch.port.to_string())
.env("DATA_DIR", launch.data_dir)
.env("STORAGE_POLICY", "text")
.env("TOKEN_SECRET", launch.token_secret)
.env_remove("LISTENERS")
.stdin(Stdio::null())
.stdout(log)
.stderr(error_log);
for name in link_assistant_router::emergency_auth::ENV_VARS {
command.env_remove(name);
}
if let Some(home) = launch.claude_home {
command
.env("CLAUDE_CODE_HOME", home)
.env("CLAUDE_CONFIG_DIR", home);
}
detach(&mut command);
let child = command.spawn().map_err(|error| {
format!(
"could not start {} serve: {error}",
launch.executable.display()
)
})?;
let pid = child.id();
self.children
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.push(child);
Ok(pid)
}
fn serving(&self, pid: u32, executable: &Path) -> bool {
if self.exited(pid) {
return false;
}
Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "command="])
.output()
.is_ok_and(|output| {
let command = String::from_utf8_lossy(&output.stdout);
output.status.success()
&& command.contains(&executable.display().to_string())
&& command.split_whitespace().any(|word| word == "serve")
})
}
fn terminate(&self, pid: u32) -> Result<(), String> {
signal("TERM", pid);
let deadline = Instant::now() + Duration::from_secs(15);
while Instant::now() < deadline {
if self.exited(pid) || !alive(pid) {
return Ok(());
}
std::thread::sleep(Duration::from_millis(100));
}
signal("KILL", pid);
std::thread::sleep(Duration::from_millis(200));
if self.exited(pid) || !alive(pid) {
Ok(())
} else {
Err(format!("host Router process {pid} did not stop"))
}
}
fn status(&self, port: u16, path: &str, bearer: Option<&str>) -> Option<u16> {
let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).ok()?;
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.ok()?;
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.ok()?;
let authorization = bearer.map_or_else(String::new, |token| {
format!("Authorization: Bearer {token}\r\n")
});
write!(
stream,
"GET {path} HTTP/1.1\r\nHost: localhost\r\n{authorization}Connection: close\r\n\r\n"
)
.ok()?;
let mut head = [0_u8; 64];
let read = stream.read(&mut head).ok()?;
String::from_utf8_lossy(&head[..read])
.split_whitespace()
.nth(1)?
.parse()
.ok()
}
fn free_port(&self) -> Result<u16, String> {
std::net::TcpListener::bind(("127.0.0.1", 0))
.and_then(|listener| listener.local_addr())
.map(|address| address.port())
.map_err(|error| format!("no free loopback port for the host candidate: {error}"))
}
fn user_id(&self) -> Option<u32> {
let output = Command::new("id").arg("-u").output().ok()?;
String::from_utf8_lossy(&output.stdout).trim().parse().ok()
}
fn claude_login(&self) -> ClaudeLogin {
if link_assistant_router::platform_keychain::lookup(
link_assistant_router::subscription::SubscriptionProvider::Claude,
)
.is_some()
{
return ClaudeLogin::Keychain;
}
let home = link_assistant_router::env_paths::directory("CLAUDE_CONFIG_DIR").or_else(|| {
link_assistant_router::env_paths::directory("HOME").map(|home| home.join(".claude"))
});
if home.is_some_and(|home| home.join(".credentials.json").is_file()) {
ClaudeLogin::File
} else {
ClaudeLogin::Absent
}
}
fn token_inventory(&self, executable: &Path, data_dir: &Path) -> Result<String, String> {
let output = Command::new(executable)
.args(["tokens", "list", "--json"])
.env("DATA_DIR", data_dir)
.env("STORAGE_POLICY", "text")
.stdin(Stdio::null())
.output()
.map_err(|error| format!("could not list tokens: {error}"))?;
if !output.status.success() {
return Err(format!(
"`router tokens list` failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
}
impl System {
fn exited(&self, pid: u32) -> bool {
let mut children = self
.children
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
children
.iter_mut()
.find(|child| child.id() == pid)
.is_some_and(|child| matches!(child.try_wait(), Ok(Some(_))))
}
}
fn open_log(path: &Path) -> Result<std::fs::File, String> {
let mut options = std::fs::OpenOptions::new();
options.create(true).append(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
options.mode(0o600);
}
let mut file = options
.open(path)
.map_err(|error| format!("could not open {}: {error}", path.display()))?;
let _ = writeln!(
file,
"--- router deploy --mode host {}",
chrono::Utc::now().to_rfc3339()
);
Ok(file)
}
#[cfg(unix)]
fn detach(command: &mut Command) {
use std::os::unix::process::CommandExt as _;
command.process_group(0);
}
#[cfg(not(unix))]
const fn detach(_command: &mut Command) {}
fn signal(name: &str, pid: u32) {
#[cfg(unix)]
let _ = Command::new("kill")
.args([&format!("-{name}"), &pid.to_string()])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
#[cfg(windows)]
let _ = (
name,
Command::new("taskkill")
.args(["/PID", &pid.to_string(), "/F"])
.status(),
);
}
fn alive(pid: u32) -> bool {
#[cfg(unix)]
{
Command::new("ps")
.args(["-p", &pid.to_string(), "-o", "stat="])
.output()
.is_ok_and(|output| {
let state = String::from_utf8_lossy(&output.stdout);
let state = state.trim();
output.status.success() && !state.is_empty() && !state.starts_with('Z')
})
}
#[cfg(windows)]
{
Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
.output()
.is_ok_and(|output| String::from_utf8_lossy(&output.stdout).contains(&pid.to_string()))
}
}