mod admin;
mod build_identity;
mod config;
mod data_dir;
mod doctor;
mod healthcheck;
mod inherit;
mod lock;
mod log;
mod revoked;
mod runtime;
mod service;
mod supervisor;
use anyhow::{Context, Result, bail};
use clap::{Args, Parser, Subcommand};
use hennery_host::identity::Paired;
use hennery_host::pairing::Joined;
use hennery_host::session::IDLE_TIMEOUT;
use hennery_host::{AgentCommand, HostConfig};
use hennery_kernel::hosts::Hosts;
use hennery_kernel::operator::{Operator, PublicUrl, SetupLink};
use hennery_sessions::{AppState, store::Store};
use std::net::SocketAddr;
use std::os::fd::AsRawFd;
use std::path::PathBuf;
#[derive(Parser)]
#[command(name = "hennery", version, long_version = build_identity::long_version(), about = "Self-hosted cockpit for ACP coding agents")]
struct Cli {
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Collector(CollectorCli),
Host {
#[command(subcommand)]
command: HostCommand,
},
Up(UpArgs),
Admin(admin::AdminArgs),
Service(service::ServiceArgs),
Doctor(doctor::DoctorArgs),
}
#[derive(Subcommand)]
enum HostCommand {
Join(JoinArgs),
Run(HostArgs),
Transport {
#[command(subcommand)]
command: TransportCommand,
},
Enrollment {
#[command(subcommand)]
command: EnrollmentCommand,
},
Adapters {
#[command(subcommand)]
command: runtime::AdaptersCommand,
},
}
#[derive(Args)]
struct JoinArgs {
url: String,
#[arg(long)]
ca_cert: Option<PathBuf>,
code: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long, env = "HENNERY_HOST_DATA_DIR")]
data_dir: Option<PathBuf>,
#[arg(long = "use-cli", value_parser = runtime::parse_use_cli)]
use_cli: Vec<hennery_host::runtime::agents::UseCli>,
#[arg(long, conflicts_with = "use_cli")]
no_runtime: bool,
#[command(flatten)]
mirrors: runtime::MirrorArgs,
}
#[derive(Subcommand)]
enum TransportCommand {
Set(TransportSetArgs),
}
#[derive(Args)]
struct TransportSetArgs {
url: String,
#[arg(long, env = "HENNERY_HOST_DATA_DIR")]
data_dir: Option<PathBuf>,
#[arg(long, conflicts_with = "system_roots")]
ca_cert: Option<PathBuf>,
#[arg(long)]
system_roots: bool,
}
#[derive(Subcommand)]
enum EnrollmentCommand {
Archive {
attempt_id: String,
#[arg(long, env = "HENNERY_HOST_DATA_DIR")]
data_dir: Option<PathBuf>,
},
}
#[derive(Args)]
#[command(args_conflicts_with_subcommands = true, subcommand_negates_reqs = true)]
struct CollectorCli {
#[command(subcommand)]
command: Option<CollectorCommand>,
#[command(flatten)]
run: CollectorArgs,
}
#[derive(Subcommand)]
enum CollectorCommand {
Healthcheck(HealthcheckArgs),
}
#[derive(Args)]
struct HealthcheckArgs {
#[arg(long = "listen", env = "HENNERY_LISTEN", value_delimiter = ',')]
listen: Vec<String>,
#[arg(long, env = "HENNERY_DATA_DIR")]
data_dir: Option<PathBuf>,
}
#[derive(Args, Clone)]
struct CollectorArgs {
#[arg(long = "listen", env = "HENNERY_LISTEN", value_delimiter = ',')]
listen: Vec<String>,
#[arg(long, env = "HENNERY_DATA_DIR")]
data_dir: Option<PathBuf>,
#[arg(long, env = "HENNERY_PUBLIC_URL")]
public_url: Option<String>,
#[arg(long, default_value_t = hennery_sessions::offline::OFFLINE_THRESHOLD.as_secs())]
host_offline_secs: u64,
#[arg(long, hide = true, value_parser = clap::value_parser!(i32).range(3..))]
pairing_code_fd: Option<i32>,
#[arg(long = "listen-fd", hide = true, conflicts_with = "listen", value_parser = clap::value_parser!(i32).range(3..))]
listen_fd: Vec<i32>,
#[arg(long, hide = true, value_parser = clap::value_parser!(i32).range(3..))]
parent_fd: Option<i32>,
}
#[derive(Args, Clone)]
struct HostArgs {
#[arg(long, env = "HENNERY_HOST_DATA_DIR")]
data_dir: Option<PathBuf>,
#[arg(long = "agent", value_parser = parse_agent)]
agents: Vec<(String, AgentCommand)>,
#[arg(long, default_value_t = IDLE_TIMEOUT.as_secs())]
idle_timeout_secs: u64,
#[arg(long, hide = true, requires = "join_code_fd")]
join_url: Option<String>,
#[arg(long, hide = true, requires = "join_url", value_parser = clap::value_parser!(i32).range(3..))]
join_code_fd: Option<i32>,
#[arg(long, hide = true)]
collector_url: Option<String>,
#[arg(long, hide = true, value_parser = clap::value_parser!(i32).range(3..))]
parent_fd: Option<i32>,
#[arg(long = "workspace-root")]
workspace_roots: Vec<String>,
#[command(flatten)]
mirrors: runtime::MirrorArgs,
}
#[derive(Args)]
struct UpArgs {
#[arg(long = "listen", env = "HENNERY_LISTEN", value_delimiter = ',')]
listen: Vec<String>,
#[arg(long, env = "HENNERY_PUBLIC_URL")]
public_url: Option<String>,
#[arg(long, env = "HENNERY_DATA_DIR")]
data_dir: Option<PathBuf>,
#[arg(long = "agent", value_parser = parse_agent)]
agents: Vec<(String, AgentCommand)>,
#[arg(long, default_value_t = IDLE_TIMEOUT.as_secs())]
idle_timeout_secs: u64,
#[arg(long = "workspace-root")]
workspace_roots: Vec<String>,
}
fn parse_agent(s: &str) -> Result<(String, AgentCommand), String> {
let (name, command) = s.split_once('=').ok_or("expected name=command")?;
let command = AgentCommand::parse(command).ok_or("empty command")?;
Ok((name.to_string(), command))
}
const DEFAULT_LISTEN: &str = "127.0.0.1:7117";
pub(crate) const MAX_LISTENERS: usize = 8;
fn listen_addresses(given: &[String]) -> Result<Vec<String>> {
let addresses: Vec<String> = if given.is_empty() {
vec![DEFAULT_LISTEN.to_string()]
} else {
given.iter().map(|a| a.trim().to_string()).collect()
};
if addresses.iter().any(String::is_empty) {
bail!("an empty listen address: give each as host:port");
}
if addresses.len() > MAX_LISTENERS {
bail!(
"{} listen addresses; at most {MAX_LISTENERS} are supported",
addresses.len()
);
}
Ok(addresses)
}
fn bind_all(addresses: &[String]) -> Result<Vec<std::net::TcpListener>> {
addresses
.iter()
.map(|address| {
let listener = std::net::TcpListener::bind(address).with_context(|| format!("bind {address}"))?;
listener.set_nonblocking(true)?;
Ok(listener)
})
.collect()
}
impl Command {
fn log_name(&self) -> Option<&'static str> {
match self {
Self::Up(_) => Some("up"),
Self::Collector(CollectorCli { command: None, .. }) => Some("collector"),
Self::Host {
command: HostCommand::Run(_),
} => Some("host"),
_ => None,
}
}
}
#[tokio::main]
async fn main() -> std::process::ExitCode {
let cli = Cli::parse();
if let Err(why) = log::init(cli.command.log_name()) {
eprintln!("Error: {why}");
return std::process::ExitCode::FAILURE;
}
let result = async { match cli.command {
Command::Collector(CollectorCli {
command: Some(CollectorCommand::Healthcheck(args)),
..
}) => Ok(collector_healthcheck(args)),
Command::Collector(CollectorCli {
run: args,
command: None,
}) => run_collector(args).await.map(|()| std::process::ExitCode::SUCCESS),
Command::Host {
command: HostCommand::Join(args),
} => join_host(args).await.map(|()| std::process::ExitCode::SUCCESS),
Command::Host {
command: HostCommand::Run(args),
} => run_host(args).await,
Command::Host {
command: HostCommand::Transport {
command: TransportCommand::Set(args),
},
} => {
let dir = data_dir::host_or_installed(args.data_dir, data_dir::HostUse::Join)?;
let _guard = hennery_host::pairing::PairingLock::acquire(&dir)?;
let trust = if let Some(path) = args.ca_cert {
Some(hennery_host::transport::CollectorTrust::import(&path)?)
} else if args.system_roots {
Some(hennery_host::transport::CollectorTrust::System)
} else {
None
};
let result = hennery_host::pairing::set_transport(&dir, &args.url, trust).await?;
if result == hennery_host::connection::Standing::Connected {
println!("another connection still uses this identity");
}
println!("transport saved; restart the host (this parks adapters) and resume sessions normally");
Ok(std::process::ExitCode::SUCCESS)
}
Command::Host {
command:
HostCommand::Enrollment {
command:
EnrollmentCommand::Archive {
attempt_id,
data_dir: given,
},
},
} => {
let dir = data_dir::host_or_installed(given, data_dir::HostUse::Join)?;
let _guard = hennery_host::pairing::PairingLock::acquire(&dir)?;
hennery_host::enrollment::archive(&dir, &attempt_id)?;
println!(
"attempt archived locally; this does not confirm remote revocation. Check Hosts and revoke any entry before a fresh join"
);
Ok(std::process::ExitCode::SUCCESS)
}
Command::Host {
command: HostCommand::Adapters { command },
} => runtime::run(command).await.map(|()| std::process::ExitCode::SUCCESS),
Command::Up(args) => run_up(args).await,
Command::Admin(args) => admin::run(args).await.map(|()| std::process::ExitCode::SUCCESS),
Command::Service(args) => service::run(args),
Command::Doctor(args) => doctor::run(args),
} }.await;
match result {
Ok(code) => code,
Err(err) => {
eprintln!("Error: {err:?}");
std::process::ExitCode::FAILURE
}
}
}
fn collector_healthcheck(args: HealthcheckArgs) -> std::process::ExitCode {
let checked = (|| {
let file = match &args.data_dir {
Some(dir) => config::FileConfig::load(dir)?,
None => config::FileConfig::default(),
};
let addresses = listen_addresses(&file.listen(&args.listen)?)?;
healthcheck::check(healthcheck::probe_address(&addresses[0])?, healthcheck::TIMEOUT)
})();
match checked {
Ok(()) => std::process::ExitCode::SUCCESS,
Err(err) => {
eprintln!("unhealthy: {err:#}");
std::process::ExitCode::FAILURE
}
}
}
async fn run_collector(args: CollectorArgs) -> Result<()> {
let data_dir = data_dir::or_default(args.data_dir.clone(), service::unit::Role::Collector)?;
let mut signals = Signals::new()?;
if let Some(fd) = args.pairing_code_fd {
inherit::check_pipe("--pairing-code-fd", fd)?;
}
let parent = args.parent_fd.map(inherit::watch_parent).transpose()?;
warn_if_dev_token();
let file = config::FileConfig::load(&data_dir)?;
let config_path = data_dir.join(config::CONFIG_FILE);
if config_path.exists() {
tracing::info!(path = %config_path.display(), "collector config loaded");
} else {
tracing::info!(path = %config_path.display(), "collector config does not exist; using flags, environment and defaults");
}
let public_url_source = if args.public_url.is_some() {
"--public-url or HENNERY_PUBLIC_URL".to_string()
} else {
data_dir.join(config::CONFIG_FILE).display().to_string()
};
let public_url = file
.public_url(args.public_url.as_deref())
.map(|url| {
PublicUrl::parse(&url)
.map_err(|why| anyhow::anyhow!("{why}"))
.with_context(|| public_url_source.clone())
})
.transpose()?;
let listeners = if args.listen_fd.is_empty() {
bind_all(&listen_addresses(&file.listen(&args.listen)?)?)?
} else {
if args.listen_fd.len() > MAX_LISTENERS {
bail!("more than {MAX_LISTENERS} --listen-fd");
}
for (i, fd) in args.listen_fd.iter().enumerate() {
if args.listen_fd[..i].contains(fd) {
bail!("--listen-fd {fd} is given twice");
}
}
args.listen_fd
.iter()
.map(|&fd| inherited_listener(fd))
.collect::<Result<_>>()?
};
private_data_dir(&data_dir)?;
let admin_socket = hennery_kernel::admin::bind(&data_dir)?;
let db = data_dir.join("hennery.db");
let store = Store::open(&db)?;
let hosts = Hosts::open(&db)?;
let operator = Operator::open(&db)?;
let mut state = AppState::new(store, hosts, operator);
state.vapid = std::sync::Arc::new(hennery_kernel::push::VapidKey::load_or_create(&data_dir)?);
let egress = hennery_kernel::egress::Egress::new(hennery_kernel::egress::Timeouts::DEFAULT)?;
start_push(&mut state, &egress);
let keys = hennery_gateway::key::KeySource::from_env(&data_dir)?;
let (gateway, proxy) = start_gateway(&db, &keys, &state, &egress, hennery_gateway::probe::EVERY).await?;
state.offline_threshold = std::time::Duration::from_secs(args.host_offline_secs);
hennery_sessions::offline::after_startup(&state);
hennery_sessions::sweep::after_startup(&state);
hennery_sessions::delegation_worker::after_startup(&state);
let listeners = listeners
.into_iter()
.map(tokio::net::TcpListener::from_std)
.collect::<std::io::Result<Vec<_>>>()
.context("serve the listening sockets")?;
let mut addresses = Vec::new();
for listener in &listeners {
let address = listener.local_addr()?;
tracing::info!(%address, "collector listening");
addresses.push(address);
}
if addresses.iter().all(|address| address.ip().is_loopback()) {
tracing::info!(
"all collector listeners are loopback; to listen elsewhere, use --listen <IP>:7117 or listen in config.toml; set --public-url or public_url to your HTTPS browser origin"
);
}
let base_url = match &public_url {
Some(url) => url.origin().to_string(),
None => format!("http://localhost:{}", addresses[0].port()),
};
warn_if_public_url_differs(state.operator.public_url().as_ref(), public_url.as_ref());
if let Some(link) = state
.operator
.announce_setup(&data_dir, &base_url, hennery_kernel::secret::unix_now())?
{
announce_setup(&link);
}
if let Some(fd) = args.pairing_code_fd {
let code = state
.hosts
.mint_local_pairing_code(hennery_kernel::secret::unix_now())?;
if let Err(err) = inherit::write_code(fd, &code.code) {
tracing::warn!(error = %err, "could not hand the pairing code to the host child");
}
}
let shutdown = state.shutdown.clone();
tokio::spawn(async move {
tokio::select! {
() = signals.recv() => {}
() = inherit::parent_gone(parent) => {}
}
shutdown.cancel();
});
let admin = admin_socket.map(|socket| {
let admin = hennery_kernel::admin::Admin {
operator: state.operator.clone(),
hosts: state.hosts.clone(),
dir: data_dir.clone(),
base_url,
};
tokio::spawn(hennery_kernel::admin::serve(
socket,
admin,
state.shutdown.clone().cancelled_owned(),
))
});
let served = hennery_sessions::serve_all(
listeners,
hennery_sessions::router(state.clone())
.merge(hennery_sessions::builtin::router(state.clone(), gateway.clone()))
.merge(hennery_gateway::api::router(gateway))
.merge(hennery_gateway::proxy::router(proxy))
.layer(axum::middleware::from_fn(
hennery_kernel::client_address::resolve_request,
))
.layer(axum::Extension(file.proxy_policy()?)),
state.shutdown.clone(),
)
.await;
state.shutdown.cancel();
if let Some(admin) = admin {
let _ = admin.await;
}
served?;
Ok(())
}
fn inherited_listener(fd: i32) -> Result<std::net::TcpListener> {
let option = |name: libc::c_int| -> std::io::Result<libc::c_int> {
let mut value: libc::c_int = 0;
let mut len = std::mem::size_of::<libc::c_int>() as libc::socklen_t;
let rc = unsafe { libc::getsockopt(fd, libc::SOL_SOCKET, name, (&raw mut value).cast(), &mut len) };
if rc < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(value)
}
};
let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
if flags < 0 {
bail!(
"--listen-fd {fd} is not an open descriptor: {}",
std::io::Error::last_os_error()
);
}
let kind = option(libc::SO_TYPE).with_context(|| format!("--listen-fd {fd} is not a socket"))?;
if kind != libc::SOCK_STREAM {
bail!("--listen-fd {fd} is not a stream (TCP) socket");
}
let family = socket_family(fd).with_context(|| format!("--listen-fd {fd}: getsockname"))?;
if family != libc::AF_INET && family != libc::AF_INET6 {
bail!("--listen-fd {fd} is not a TCP socket (address family {family}, not IPv4 or IPv6)");
}
let listening = match option(libc::SO_ACCEPTCONN) {
Ok(value) => value == 1,
Err(err) if err.raw_os_error() == Some(libc::ENOPROTOOPT) => {
listens_by_tcp_state(fd).with_context(|| format!("--listen-fd {fd}: TCP_CONNECTION_INFO"))?
}
Err(err) => return Err(err).with_context(|| format!("--listen-fd {fd}: SO_ACCEPTCONN")),
};
if !listening {
bail!("--listen-fd {fd} is not a listening socket");
}
if unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) } < 0 {
return Err(std::io::Error::last_os_error()).context("make --listen-fd close-on-exec");
}
let listener = unsafe { <std::net::TcpListener as std::os::fd::FromRawFd>::from_raw_fd(fd) };
listener.set_nonblocking(true)?;
Ok(listener)
}
fn socket_family(fd: i32) -> std::io::Result<libc::c_int> {
unsafe {
let mut addr: libc::sockaddr_storage = std::mem::zeroed();
let mut len = std::mem::size_of::<libc::sockaddr_storage>() as libc::socklen_t;
if libc::getsockname(fd, (&raw mut addr).cast(), &mut len) < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(libc::c_int::from(addr.ss_family))
}
}
#[cfg(target_vendor = "apple")]
fn listens_by_tcp_state(fd: i32) -> std::io::Result<bool> {
const TCPS_LISTEN: u8 = 1;
let mut info: libc::tcp_connection_info = unsafe { std::mem::zeroed() };
let mut len = std::mem::size_of::<libc::tcp_connection_info>() as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
fd,
libc::IPPROTO_TCP,
libc::TCP_CONNECTION_INFO,
(&raw mut info).cast(),
&mut len,
)
};
if rc < 0 {
return Err(std::io::Error::last_os_error());
}
Ok(info.tcpi_state == TCPS_LISTEN)
}
#[cfg(not(target_vendor = "apple"))]
fn listens_by_tcp_state(_fd: i32) -> std::io::Result<bool> {
Err(std::io::Error::from_raw_os_error(libc::ENOPROTOOPT))
}
const DEV_TOKEN_VAR: &str = "HENNERY_DEV_TOKEN";
fn warn_if_dev_token() {
if std::env::var_os(DEV_TOKEN_VAR).is_some() {
tracing::warn!(
"{DEV_TOKEN_VAR} is set but no longer used: since operator auth (3b-i), operators sign in through \
the setup link and a password; remove it from the environment"
);
}
}
fn warn_if_public_url_differs(stored: Option<&PublicUrl>, configured: Option<&PublicUrl>) {
if let (Some(stored), Some(configured)) = (stored, configured)
&& stored != configured
{
tracing::warn!(
stored = stored.origin(),
configured = configured.origin(),
"the configured public_url is not the one setup stored, which stays in effect; \
run `hennery admin reset-public-url` to move it"
);
}
}
fn announce_setup(link: &SetupLink) {
use std::io::IsTerminal;
if std::io::stdout().is_terminal() {
println!(
"hennery is not set up yet. Open this link within the hour to set it up:\n {}",
link.url
);
} else {
tracing::info!(
file = %link.file.display(),
"hennery is not set up yet; the one-time setup link (valid for an hour) is in this file"
);
}
}
fn private_data_dir(dir: &std::path::Path) -> Result<()> {
hennery_host::identity::create_private_dir(dir)?;
if !hennery_host::identity::is_private(dir)? {
tracing::warn!(
dir = %dir.display(),
"the data directory is readable by other users; `chmod 700` it"
);
}
Ok(())
}
async fn join_host(args: JoinArgs) -> Result<()> {
let data_dir = data_dir::host_or_installed(args.data_dir.clone(), data_dir::HostUse::Join)?;
hennery_host::identity::create_private_dir(&data_dir)?;
let _guard = hennery_host::pairing::PairingLock::acquire(&data_dir)?;
let trust = args
.ca_cert
.as_deref()
.map(hennery_host::transport::CollectorTrust::import)
.transpose()?;
let prepared = hennery_host::pairing::prepare_join(&args.url, trust, &data_dir).await?;
if !args.no_runtime {
args.mirrors.sources()?;
}
let name = args.name.unwrap_or_else(hennery_host::pairing::default_name);
let code = match (&prepared, args.code) {
(hennery_host::pairing::PreparedJoin::Already(_), _) => String::new(),
(_, Some(code)) => code,
(_, None) => read_code_from_stdin()?,
};
let host_id = match hennery_host::pairing::complete_join(prepared, &code, &data_dir, &name).await? {
Joined::Paired { host_id } => {
println!("paired as {host_id}");
host_id
}
Joined::AlreadyPaired { host_id } => {
println!("already paired as {host_id}");
host_id
}
};
if args.no_runtime {
return Ok(());
}
runtime::after_join(&data_dir, &host_id, &args.use_cli, &args.mirrors).await
}
const MAX_CODE_LINE: usize = 256;
fn read_code_from_stdin() -> Result<String> {
use std::io::{BufRead, IsTerminal, Read, Write};
if std::io::stdin().is_terminal() {
eprint!("Pairing code: ");
std::io::stderr().flush()?;
}
let mut line = Vec::new();
std::io::stdin()
.lock()
.take(MAX_CODE_LINE as u64 + 1)
.read_until(b'\n', &mut line)
.context("read the pairing code from standard input")?;
if line.len() > MAX_CODE_LINE && line.last() != Some(&b'\n') {
bail!("the pairing code on standard input is longer than {MAX_CODE_LINE} bytes");
}
let Ok(line) = String::from_utf8(line) else {
bail!("the pairing code on standard input is not UTF-8");
};
let code = line.trim().to_string();
if code.is_empty() {
bail!("no pairing code: give it after the URL, or on standard input");
}
Ok(code)
}
async fn run_host(args: HostArgs) -> Result<std::process::ExitCode> {
let data_dir = data_dir::host_or_installed(args.data_dir.clone(), data_dir::HostUse::Run)?;
if let Some(fd) = args.join_code_fd {
inherit::check_pipe("--join-code-fd", fd)?;
}
let parent = args.parent_fd.map(inherit::watch_parent).transpose()?;
let under_up = args.parent_fd.is_some();
hennery_host::adapter::prepare_death_pipe().context("the adapters' death pipe")?;
warn_if_dev_token();
let home = hennery_host::projects::home_dir();
if !args.workspace_roots.is_empty() {
hennery_host::projects::workspace_roots(&args.workspace_roots, &[], home.as_deref())?;
}
if !data_dir.exists() && args.join_url.is_some() {
hennery_host::identity::create_private_dir(&data_dir)?;
}
if !data_dir.is_dir() {
bail!(
"{} holds no pairing; run `hennery host join <url> <code>` first",
data_dir.display()
);
}
let _lock = lock::acquire(&data_dir, lock::HOST_LOCK, "hennery host run")?;
let paired = match Paired::load(&data_dir)? {
Some(paired) => {
if let Some(fd) = args.join_code_fd {
inherit::close(fd);
}
paired
}
None => {
hennery_host::enrollment::require_automatic_join_allowed(&data_dir)?;
let (Some(url), Some(fd)) = (&args.join_url, args.join_code_fd) else {
bail!(
"{} holds no pairing; run `hennery host join <url> <code>` first",
data_dir.display()
);
};
let code = inherit::read_code(fd).await?;
let name = hennery_host::pairing::default_name();
hennery_host::pairing::join_locked(url, &code, &data_dir, &name, None).await?;
Paired::load(&data_dir)?.context("the pairing just stored")?
}
};
let collector_url = args
.collector_url
.clone()
.unwrap_or_else(|| paired.collector_url.clone());
let endpoint = hennery_host::transport::CollectorEndpoint::stored(&collector_url)?;
hennery_host::transport::CollectorEndpoint::stored(&paired.collector_url)?.require_compatible(&endpoint)?;
hennery_host::transport::CollectorTransport::new(endpoint, paired.trust.clone())?;
let workspace_roots =
hennery_host::projects::workspace_roots(&args.workspace_roots, &paired.workspace_roots, home.as_deref())?;
let (agents, profiles, codex_app_server, _set_in_use) = if args.agents.is_empty() {
runtime::default_agents(&data_dir, &args.mirrors).await
} else {
(args.agents.into_iter().collect(), Default::default(), None, None)
};
let paired_collector = paired.collector_url.clone();
let record_dir = data_dir.clone();
let revoke = revoked::Revoked {
host_id: paired.host_id.clone(),
collector_url: paired_collector,
at: 0,
};
let mut cfg = HostConfig::new(collector_url, paired.host_id, paired.key, data_dir);
cfg.trust = paired.trust;
cfg.workspace_roots = workspace_roots;
cfg.home = home;
cfg.agents = agents;
cfg.profiles = profiles;
cfg.codex_app_server = codex_app_server;
cfg.idle_timeout = std::time::Duration::from_secs(args.idle_timeout_secs);
let shutdown = async {
tokio::select! {
() = terminated() => {}
() = inherit::parent_gone(parent) => {}
}
};
match hennery_host::run_until(cfg, shutdown).await {
Err(err) if hennery_host::connection::revoked(&err) => {
eprintln!("Error: {err:#}");
let revoke = revoked::Revoked {
at: hennery_kernel::secret::unix_now(),
..revoke
};
if let Err(why) = revoked::record(&record_dir, &revoke) {
eprintln!("warning: the revoke could not be recorded: {why:#}");
}
let code = revoked::exit_code(std::env::var("HENNERY_SERVICE").ok().as_deref(), under_up);
if code == 0 {
let uid = unsafe { libc::getuid() };
let start = format!("launchctl kickstart -k gui/{uid}/{}", service::unit::Role::Host.label());
let (_, fix) = revoke.said(&start);
eprintln!(
"this host stays down: launchd does not start it again after exit 0. To bring it back, {fix}"
);
}
Ok(std::process::ExitCode::from(code))
}
Err(err) => Err(err),
Ok(()) => Ok(std::process::ExitCode::SUCCESS),
}
}
async fn terminated() {
let mut term = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()).expect("SIGTERM handler");
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = term.recv() => {}
}
}
struct Signals {
interrupt: tokio::signal::unix::Signal,
terminate: tokio::signal::unix::Signal,
}
impl Signals {
fn new() -> Result<Self> {
use tokio::signal::unix::{SignalKind, signal};
Ok(Self {
interrupt: signal(SignalKind::interrupt()).context("SIGINT handler")?,
terminate: signal(SignalKind::terminate()).context("SIGTERM handler")?,
})
}
async fn recv(&mut self) {
tokio::select! {
_ = self.interrupt.recv() => {}
_ = self.terminate.recv() => {}
}
}
}
fn loopback_url(listen: &str) -> String {
match listen.parse::<SocketAddr>() {
Ok(addr) if addr.ip().is_unspecified() && addr.is_ipv6() => format!("http://[::1]:{}", addr.port()),
Ok(addr) if addr.ip().is_unspecified() => format!("http://127.0.0.1:{}", addr.port()),
_ => format!("http://{listen}"),
}
}
fn host_command(
exe: &std::path::Path,
host_dir: &std::path::Path,
collector_ws_url: &str,
args: &UpArgs,
) -> tokio::process::Command {
let mut host_cmd = tokio::process::Command::new(exe);
host_cmd
.args(["host", "run"])
.arg("--data-dir")
.arg(host_dir)
.arg("--collector-url")
.arg(collector_ws_url)
.arg("--idle-timeout-secs")
.arg(args.idle_timeout_secs.to_string())
.kill_on_drop(true)
.process_group(0);
for var in hennery_host::adapter::HOST_SECRET_VARS {
host_cmd.env_remove(var);
}
for root in &args.workspace_roots {
host_cmd.arg("--workspace-root").arg(root);
}
for (name, command) in &args.agents {
let mut spec = format!("{name}={}", command.program);
for a in &command.args {
spec.push(' ');
spec.push_str(a);
}
host_cmd.arg("--agent").arg(spec);
}
host_cmd
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Pairing {
None,
Whole,
Interrupted,
}
fn pairing_in(dir: &std::path::Path) -> Pairing {
use hennery_host::identity::{CONFIG_FILE, KEY_FILE};
if dir.join(format!("{CONFIG_FILE}.pending")).exists() || hennery_host::enrollment::has_artifacts(dir) {
Pairing::Interrupted
} else if dir.join(KEY_FILE).is_file() && dir.join(CONFIG_FILE).is_file() {
Pairing::Whole
} else {
Pairing::None
}
}
struct UpChildren<'a> {
exe: PathBuf,
args: &'a UpArgs,
data_dir: PathBuf,
host_dir: PathBuf,
collector_url: String,
collector_ws_url: String,
listeners: Vec<std::net::TcpListener>,
parent: (std::io::PipeReader, std::io::PipeWriter),
}
impl UpChildren<'_> {
fn collector(&self, pairing: Option<&std::io::PipeWriter>) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new(&self.exe);
cmd.arg("collector");
let mut fds = Vec::new();
for (listener, to) in self.listeners.iter().zip(inherit::LISTENER_FD..) {
cmd.arg("--listen-fd").arg(to.to_string());
fds.push((listener.as_raw_fd(), to));
}
cmd.arg("--data-dir")
.arg(self.data_dir.join("collector"))
.env_remove(DEV_TOKEN_VAR)
.env_remove("HENNERY_LISTEN")
.kill_on_drop(true)
.process_group(0);
if let Some(public_url) = &self.args.public_url {
cmd.arg("--public-url").arg(public_url);
}
if let Some(writer) = pairing {
fds.push((writer.as_raw_fd(), inherit::CHILD_FD));
cmd.arg("--pairing-code-fd").arg(inherit::CHILD_FD.to_string());
}
fds.push((self.parent.0.as_raw_fd(), inherit::PARENT_FD));
cmd.arg("--parent-fd").arg(inherit::PARENT_FD.to_string());
inherit::pass_to_child(&mut cmd, &fds);
cmd
}
fn host(&self, pairing: Option<&std::io::PipeReader>) -> tokio::process::Command {
let mut cmd = host_command(&self.exe, &self.host_dir, &self.collector_ws_url, self.args);
let mut fds = vec![(self.parent.0.as_raw_fd(), inherit::PARENT_FD)];
cmd.arg("--parent-fd").arg(inherit::PARENT_FD.to_string());
if let Some(reader) = pairing {
fds.push((reader.as_raw_fd(), inherit::CHILD_FD));
cmd.arg("--join-url")
.arg(&self.collector_url)
.arg("--join-code-fd")
.arg(inherit::CHILD_FD.to_string());
}
inherit::pass_to_child(&mut cmd, &fds);
cmd
}
}
impl supervisor::Children for UpChildren<'_> {
type Child = tokio::process::Child;
fn spawn(&mut self, which: supervisor::Which) -> Result<tokio::process::Child> {
let mut cmd = match which {
supervisor::Which::Collector => self.collector(None),
supervisor::Which::Host => self.host(None),
};
Ok(cmd.spawn()?)
}
fn host_paired(&self) -> bool {
pairing_in(&self.host_dir) == Pairing::Whole
}
fn revoked(&self) {
tracing::warn!(
"the all-in-one host was revoked; the collector keeps serving. To pair it again, stop `hennery up`, remove {} and {}, and start it again",
self.host_dir.join(hennery_host::identity::KEY_FILE).display(),
self.host_dir.join(hennery_host::identity::CONFIG_FILE).display()
);
}
}
async fn run_up(args: UpArgs) -> Result<std::process::ExitCode> {
let data_dir = data_dir::or_default(args.data_dir.clone(), service::unit::Role::Up)?;
let mut signals = Signals::new()?;
warn_if_dev_token();
let exe = std::env::current_exe()?;
let host_dir = data_dir.join("host");
let file = config::FileConfig::load(&data_dir.join("collector"))?;
let addresses = listen_addresses(&file.listen(&args.listen)?)?;
let Some(host_listener) = addresses.iter().position(|address| {
let candidate = loopback_url(address);
let candidate = candidate
.strip_suffix(":0")
.map(|base| format!("{base}:1"))
.unwrap_or(candidate);
hennery_host::pairing::collector_ws_url(&candidate).is_ok()
}) else {
let err = hennery_host::pairing::collector_ws_url(&loopback_url(&addresses[0]))
.expect_err("no address passed the check");
return Err(err.context("the all-in-one host reaches its collector over loopback"));
};
let listeners = bind_all(&addresses)?;
let collector_url = loopback_url(&listeners[host_listener].local_addr()?.to_string());
let collector_ws_url = hennery_host::pairing::collector_ws_url(&collector_url)?;
private_data_dir(&data_dir)?;
let _lock = lock::acquire(&data_dir, lock::UP_LOCK, "hennery up")?;
if host_dir.is_dir() {
let _guard = hennery_host::pairing::PairingLock::acquire(&host_dir)?;
hennery_host::enrollment::require_resolved(&host_dir)?;
if let Some(stored) = hennery_host::identity::stored_endpoint(&host_dir)? {
stored.require_compatible(&hennery_host::transport::CollectorEndpoint::stored(&collector_ws_url)?)?;
}
}
let pairing = match pairing_in(&host_dir) {
Pairing::Whole | Pairing::Interrupted => None,
Pairing::None => {
hennery_host::enrollment::require_automatic_join_allowed(&host_dir)?;
Some(std::io::pipe()?)
}
};
let mut children = UpChildren {
exe,
args: &args,
data_dir: data_dir.clone(),
host_dir,
collector_url,
collector_ws_url,
listeners,
parent: std::io::pipe()?,
};
let collector = children
.collector(pairing.as_ref().map(|(_, writer)| writer))
.spawn()
.context("pass the listening sockets to the collector")?;
let host = children.host(pairing.as_ref().map(|(reader, _)| reader)).spawn()?;
drop(pairing);
let report = |collector: &supervisor::ChildReport, host: &supervisor::ChildReport| {
let state = supervisor::State {
pid: std::process::id(),
updated_at: hennery_kernel::secret::unix_now().try_into().unwrap_or(0),
collector: collector.clone(),
host: host.clone(),
};
if let Err(err) = supervisor::write_state(&data_dir, &state) {
tracing::warn!(error = %format!("{err:#}"), "could not write {}", supervisor::STATE_FILE);
}
};
let outcome = supervisor::supervise(
&mut children,
collector,
host,
&supervisor::POLICY,
signals.recv(),
report,
)
.await;
match outcome {
supervisor::Outcome::Stopped => Ok(std::process::ExitCode::SUCCESS),
supervisor::Outcome::Failed(why) => {
eprintln!("Error: {why}");
Ok(std::process::ExitCode::FAILURE)
}
}
}
async fn start_gateway(
db: &std::path::Path,
keys: &hennery_gateway::key::KeySource,
state: &AppState,
egress: &hennery_kernel::egress::Egress,
every: std::time::Duration,
) -> Result<(hennery_gateway::api::GatewayState, hennery_gateway::proxy::ProxyState)> {
let gateway = hennery_gateway::open(
db,
keys,
state.operator.clone(),
egress.clone(),
std::sync::Arc::new(state.push.clone()),
)?;
state.store.set_session_mcp(std::sync::Arc::new(
hennery_gateway::session::GatewayMcp::for_collector(&gateway),
));
gateway.runtime.recover_plugins().await?;
gateway.runtime.ensure_plugin_worker();
gateway.runtime.announce_startup()?;
let proxy = hennery_gateway::proxy::ProxyState::for_sessions(
gateway.runtime.clone(),
hennery_gateway::proxy::Limits::default(),
);
tokio::spawn(hennery_gateway::probe::run(
gateway.runtime.clone(),
every,
hennery_gateway::probe::TICK_TIMEOUT,
state.shutdown.clone().cancelled_owned(),
));
Ok((gateway, proxy))
}
fn start_push(state: &mut AppState, egress: &hennery_kernel::egress::Egress) {
state.push =
hennery_kernel::delivery::spawn(state.hosts.clone(), state.operator.clone(), state.vapid.clone(), egress);
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn the_collector_gives_its_sessions_the_gateway() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("hennery.db");
let state = AppState::new(
Store::open(&db).unwrap(),
Hosts::open(&db).unwrap(),
Operator::open(&db).unwrap(),
);
assert!(!state.store.has_session_mcp());
let keys = hennery_gateway::key::KeySource::from_vars(dir.path(), Some("07".repeat(32).into()), None).unwrap();
let egress = hennery_kernel::egress::Egress::new(hennery_kernel::egress::Timeouts::DEFAULT).unwrap();
let _routes = start_gateway(&db, &keys, &state, &egress, hennery_gateway::probe::EVERY)
.await
.unwrap();
assert!(state.store.has_session_mcp());
}
#[tokio::test]
async fn the_gateway_announces_through_push_and_probes() {
use hennery_gateway::model::{AuthMethod, CredKind, GrantTokens, NewConnection, Status, TokenClient};
use hennery_gateway::store::{ClientSource, GrantToStore};
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("hennery.db");
let mut state = AppState::new(
Store::open(&db).unwrap(),
Hosts::open(&db).unwrap(),
Operator::open(&db).unwrap(),
);
let (push, mut notices) = hennery_kernel::push::Push::new();
state.push = push;
let egress = hennery_kernel::egress::Egress::new(hennery_kernel::egress::Timeouts::DEFAULT).unwrap();
let keys = hennery_gateway::key::KeySource::from_vars(dir.path(), None, None).unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/mcp", listener.local_addr().unwrap());
let (first, _) = start_gateway(&db, &keys, &state, &egress, std::time::Duration::from_secs(3600))
.await
.unwrap();
let runtime = first.runtime.clone();
let hennery_gateway::model::Change::Done(record) = runtime
.store
.create(
&NewConnection {
slug: "linear".into(),
label: "Linear".into(),
url: url.clone(),
hat_id: state.hosts.default_hat_for_new_hosts().unwrap(),
cred_kind: CredKind::OauthDcr,
static_header: None,
static_prefix: None,
tool_allowlist: None,
internal_network: true,
},
1,
)
.unwrap()
else {
panic!("not created");
};
let client = TokenClient {
client_id: "c".into(),
secret: None,
auth_method: AuthMethod::None,
token_endpoint: format!("{url}/token"),
};
let tokens = GrantTokens::from_opened(br#"{"access_token":"a"}"#).unwrap();
runtime
.store
.store_grant(
&GrantToStore {
connection_id: &record.id,
url: &url,
cred_kind: CredKind::OauthDcr,
internal_network: true,
source: ClientSource::Registered,
client: &client,
issuer: &url,
authorization_endpoint: &url,
redirect_uri: "https://hennery.example/api/mcp/oauth/callback",
scopes: &[],
resource: &url,
resource_param_accepted: true,
registered_at: 1,
tokens: &tokens,
expires_at: None,
},
&runtime.key,
1,
)
.unwrap();
runtime
.statuses
.record_status(&record.id, &url, Status::Error, Some("down"), 1)
.unwrap();
while notices.try_recv().is_some() {}
drop((first, runtime));
let started = start_gateway(&db, &keys, &state, &egress, std::time::Duration::from_millis(20))
.await
.unwrap();
assert!(std::sync::Arc::ptr_eq(&started.0.runtime, &started.1.runtime));
let notice = notices.try_recv().expect("the problem is announced at startup");
assert_eq!(notice.tag, format!("mcp-{}", record.id));
assert_eq!(notice.body, "is failing");
assert_eq!(notice.url, "/mcp");
assert!(notices.try_recv().is_none(), "once");
let accepted = tokio::time::timeout(std::time::Duration::from_secs(10), listener.accept()).await;
assert!(accepted.is_ok(), "the probe never ran");
state.shutdown.cancel();
}
#[tokio::test]
async fn a_configured_start_recovers_later_store_transitions_without_an_http_retry() {
use hennery_gateway::{
model::{Change, CredKind, NewConnection},
plugin::RegistrationInput,
};
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("hennery.db");
let state = AppState::new(
Store::open(&db).unwrap(),
Hosts::open(&db).unwrap(),
Operator::open(&db).unwrap(),
);
state
.hosts
.register(
"plugin-host",
&hennery_kernel::hosts::Enrollment {
public_key: hennery_host::identity::HostKey::from_seed([7; 32]).public_key_hex(),
name: "Test".into(),
host_version: "0.0.0".into(),
platform: "test".into(),
},
1,
)
.unwrap();
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}/mcp", listener.local_addr().unwrap());
let (sent, mut seen) = tokio::sync::mpsc::unbounded_channel();
let app = axum::Router::new().route(
"/mcp",
axum::routing::post(
move |hennery_kernel::json::ApiJson(body): hennery_kernel::json::ApiJson<serde_json::Value>| {
let sent = sent.clone();
async move {
sent.send(body.clone()).unwrap();
axum::Json(serde_json::json!({"jsonrpc":"2.0","id":body["id"],"result":{
"request_id":body["id"],"status":"confirmed","remaining_kinds":[]}}))
}
},
),
);
let server = tokio::spawn(async move {
axum::serve(listener, app).await.unwrap();
});
let keys = hennery_gateway::key::KeySource::from_vars(dir.path(), None, None).unwrap();
let egress = hennery_kernel::egress::Egress::new(hennery_kernel::egress::Timeouts::DEFAULT).unwrap();
let first = hennery_gateway::open(
&db,
&keys,
state.operator.clone(),
egress.clone(),
std::sync::Arc::new(hennery_gateway::notify::Silent),
)
.unwrap();
let Change::Done(connection) = first
.runtime
.store
.create(
&NewConnection {
slug: "plugin".into(),
label: "Test plug-in".into(),
url,
hat_id: state.hosts.default_hat_for_new_hosts().unwrap(),
cred_kind: CredKind::Static,
static_header: None,
static_prefix: None,
tool_allowlist: Some(vec!["search".into()]),
internal_network: true,
},
1,
)
.unwrap()
else {
panic!("connection")
};
first
.runtime
.store
.replace_mounts(&connection.id, &["plugin-host".into()])
.unwrap();
first
.runtime
.store
.set_static_credential(&connection.id, "namespace", &first.runtime.key, 1)
.unwrap();
let fingerprint = first.runtime.store.plugin_fingerprint(&connection.id).unwrap();
first
.runtime
.store
.register_plugin(
RegistrationInput {
connection_id: &connection.id,
lifecycle_credential: "separate-lifecycle",
activity: false,
startup: false,
capabilities: serde_json::from_value(
serde_json::json!({"version":1,"activity_v1":true,"forget_v1":true,"startup_context_v1":false}),
)
.unwrap(),
},
&first.runtime.key,
&fingerprint,
1,
)
.unwrap();
drop(first);
let started = start_gateway(&db, &keys, &state, &egress, std::time::Duration::from_secs(3600))
.await
.unwrap();
let error = started
.0
.runtime
.store
.update(
&connection.id,
&hennery_gateway::model::ConnectionPatch {
label: Some("Changed".into()),
..Default::default()
},
hennery_kernel::secret::unix_now(),
)
.unwrap_err();
assert!(error.is::<hennery_gateway::plugin::PendingMutation>());
let body = tokio::time::timeout(std::time::Duration::from_secs(3), seen.recv())
.await
.unwrap()
.unwrap();
assert_eq!(body["method"], "hennery_plugin_forget");
tokio::time::timeout(std::time::Duration::from_secs(2), async {
loop {
if !started.0.runtime.store.plugin_deletion_receipts().unwrap().is_empty() {
break;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
state.shutdown.cancel();
server.abort();
let _ = server.await;
}
#[tokio::test]
async fn the_collectors_notices_go_to_a_public_only_delivery() {
let dir = tempfile::tempdir().unwrap();
let db = dir.path().join("hennery.db");
let mut state = AppState::new(
Store::open(&db).unwrap(),
Hosts::open(&db).unwrap(),
Operator::open(&db).unwrap(),
);
let now = hennery_kernel::secret::unix_now();
let token = state.operator.issue_setup_token(now).unwrap().unwrap();
state
.operator
.set_up(&token, "correct horse battery", "https://hennery.example", now)
.unwrap();
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
rusqlite::Connection::open(&db)
.unwrap()
.execute(
"INSERT INTO push_subscriptions(id, owner_id, endpoint, p256dh, auth, device_label, auth_session, created_at)
VALUES ('push-a', ?1, ?2,
'BLn9b-VR0ca83knDNZ32dCHGyjJp-1riX9ZTN40MqV8K_LpQmLqxC_DoHvqvFXO_nGdAB4W9dogZb_sM-uV4JbY',
'_ordMnz7uTCmrpBTeUV4Bw', 'test', 's', ?3)",
rusqlite::params![
state.hosts.owner_id(),
format!("http://{}/push", listener.local_addr().unwrap()),
now
],
)
.unwrap();
start_push(
&mut state,
&hennery_kernel::egress::Egress::new(hennery_kernel::egress::Timeouts::DEFAULT).unwrap(),
);
state.push.notify(hennery_kernel::push::Notice {
hat_id: state.hosts.default_hat_for_new_hosts().unwrap(),
urgency: hennery_kernel::push::Urgency::Normal,
title: "t".into(),
generic_title: "g".into(),
body: "finished".into(),
detail: None,
url: "/sessions/s1".into(),
tag: "s1".into(),
});
let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
let error = loop {
if let Some(error) = state.hosts.subscriptions().unwrap()[0].last_error.clone() {
break error;
}
assert!(
tokio::time::Instant::now() < deadline,
"the notice never reached delivery"
);
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
};
assert!(error.contains("public address"), "{error}");
listener.set_nonblocking(true).unwrap();
assert!(listener.accept().is_err(), "nothing connected");
}
#[test]
fn ups_host_child_does_not_inherit_the_operator_token() {
let args = UpArgs {
listen: vec!["127.0.0.1:7117".into()],
public_url: None,
data_dir: Some("/nonexistent".into()),
agents: Vec::new(),
idle_timeout_secs: 0,
workspace_roots: Vec::new(),
};
let cmd = host_command(
std::path::Path::new("/bin/hennery"),
std::path::Path::new("/nonexistent/host"),
"ws://127.0.0.1:7117/api/hosts/ws",
&args,
);
let removed = cmd
.as_std()
.get_envs()
.any(|(key, value)| key == "HENNERY_DEV_TOKEN" && value.is_none());
assert!(removed, "the host child inherits HENNERY_DEV_TOKEN");
}
#[test]
fn ups_host_child_does_not_inherit_the_master_key() {
assert!(hennery_host::adapter::HOST_SECRET_VARS.contains(&hennery_gateway::key::KEY_ENV));
let args = UpArgs {
listen: vec!["127.0.0.1:7117".into()],
public_url: None,
data_dir: Some("/nonexistent".into()),
agents: Vec::new(),
idle_timeout_secs: 0,
workspace_roots: Vec::new(),
};
let cmd = host_command(
std::path::Path::new("/bin/hennery"),
std::path::Path::new("/nonexistent/host"),
"ws://127.0.0.1:7117/api/hosts/ws",
&args,
);
let removed = cmd
.as_std()
.get_envs()
.any(|(key, value)| key == hennery_gateway::key::KEY_ENV && value.is_none());
assert!(removed, "the host child inherits HENNERY_MASTER_KEY");
}
#[test]
fn ups_host_child_gets_its_workspace_roots() {
let args = UpArgs {
listen: vec!["127.0.0.1:7117".into()],
public_url: None,
data_dir: Some("/nonexistent".into()),
agents: Vec::new(),
idle_timeout_secs: 0,
workspace_roots: vec!["/srv/projects".into(), "~/src".into()],
};
let cmd = host_command(
std::path::Path::new("/bin/hennery"),
std::path::Path::new("/nonexistent/host"),
"ws://127.0.0.1:7117/api/hosts/ws",
&args,
);
let argv: Vec<String> = cmd
.as_std()
.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect();
let roots: Vec<&str> = argv
.windows(2)
.filter(|w| w[0] == "--workspace-root")
.map(|w| w[1].as_str())
.collect();
assert_eq!(roots, ["/srv/projects", "~/src"]);
}
#[test]
fn the_pairing_is_judged_from_the_files_alone() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(pairing_in(dir.path()), Pairing::None);
std::fs::write(dir.path().join("host.key"), "k").unwrap();
assert_eq!(pairing_in(dir.path()), Pairing::None);
std::fs::write(dir.path().join("host.toml"), "t").unwrap();
assert_eq!(pairing_in(dir.path()), Pairing::Whole);
std::fs::write(dir.path().join("host.toml.pending"), "staged").unwrap();
assert_eq!(pairing_in(dir.path()), Pairing::Interrupted);
assert_eq!(
std::fs::read_to_string(dir.path().join("host.toml.pending")).unwrap(),
"staged"
);
assert_eq!(pairing_in(&dir.path().join("missing")), Pairing::None);
}
}