use std::ffi::OsString;
use std::io::IsTerminal;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result};
use bitrouter_sdk::error::BitrouterError;
use bitrouter_sdk::invocation;
use clap::ValueEnum;
use serde::Serialize;
use crate::output::CliReport;
use crate::output::human::Human;
use crate::style::Palette;
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
pub enum SpawnAgent {
Claude,
Codex,
}
impl SpawnAgent {
pub fn spec(self) -> AgentSpec {
match self {
SpawnAgent::Claude => AgentSpec {
id: "claude",
binary: "claude",
},
SpawnAgent::Codex => AgentSpec {
id: "codex",
binary: "codex",
},
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct AgentSpec {
pub id: &'static str,
pub binary: &'static str,
}
pub fn resolve_launch_agent(id: &str) -> Result<&'static crate::harness::Harness> {
let known = crate::harness::by_interactive_binary(id)
.or_else(|| crate::harness::by_id(id).filter(|h| h.interactive_binary.is_some()));
known.ok_or_else(|| {
anyhow::Error::new(BitrouterError::bad_request(format!(
"'{id}' is not a launchable harness — available: {}",
launchable().join(", ")
)))
})
}
fn launchable() -> Vec<&'static str> {
let mut names: Vec<&str> = crate::harness::CATALOG
.iter()
.filter(|h| h.launch_supported())
.filter_map(|h| h.interactive_binary)
.collect();
names.sort_unstable();
names
}
fn harness_id(h: &crate::harness::Harness) -> &'static str {
h.interactive_binary.unwrap_or(h.id)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ChildLaunch {
pub env: Vec<(String, String)>,
pub args_prefix: Vec<String>,
}
pub(crate) fn resolve_launch_token(
parent_auth: Option<String>,
bitrouter_key: Option<String>,
) -> String {
parent_auth
.or(bitrouter_key)
.unwrap_or_else(mint_launch_token)
}
fn mint_launch_token() -> String {
format!(
"{}{}",
bitrouter_sdk::caller::LAUNCH_TOKEN_PREFIX,
uuid::Uuid::new_v4().simple()
)
}
pub(crate) fn is_launch_token(token: &str) -> bool {
bitrouter_sdk::caller::launch_tag(Some(&format!("Bearer {token}"))).is_some()
}
pub struct SpawnOptions {
pub agent: &'static crate::harness::Harness,
pub model: Option<String>,
pub agent_args: Vec<String>,
pub base_url: Option<String>,
pub no_install: bool,
pub no_start: bool,
pub check: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum SpawnCheckStatus {
Pass,
Warn,
Fail,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct SpawnCheckRow {
pub name: String,
pub status: SpawnCheckStatus,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SpawnCheckReport {
pub agent: String,
pub base_url: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub model: Option<String>,
pub checks: Vec<SpawnCheckRow>,
}
impl SpawnCheckReport {
fn has_failures(&self) -> bool {
self.checks
.iter()
.any(|c| matches!(c.status, SpawnCheckStatus::Fail))
}
}
impl CliReport for SpawnCheckReport {
fn render(&self, h: &mut Human<'_>) -> std::io::Result<()> {
h.line(&format!(
"preflight for {} via {}",
self.agent, self.base_url
))?;
if let Some(model) = &self.model {
h.line(&format!(" model: {model}"))?;
}
h.blank()?;
for check in &self.checks {
h.line(&format!(
" {} {}: {}",
match check.status {
SpawnCheckStatus::Pass => "✓",
SpawnCheckStatus::Warn => "!",
SpawnCheckStatus::Fail => "✗",
},
check.name,
check.message
))?;
}
Ok(())
}
fn exit_code(&self) -> i32 {
if self.has_failures() { 1 } else { 0 }
}
}
pub struct Prepared<'a> {
pub binary: PathBuf,
pub launch: ChildLaunch,
pub agent_args: Vec<String>,
pub harness: &'static crate::harness::Harness,
pub source: &'a crate::paths::ConfigSource,
pub launch_id: Option<String>,
pub session_start: chrono::DateTime<chrono::Utc>,
}
pub async fn run(
source: &crate::paths::ConfigSource,
cfg: &bitrouter_sdk::config::Config,
opts: SpawnOptions,
) -> Result<()> {
let prepared = prepare(source, cfg, opts).await?;
exec_inherited(prepared).await
}
pub async fn prepare<'a>(
source: &'a crate::paths::ConfigSource,
cfg: &bitrouter_sdk::config::Config,
opts: SpawnOptions,
) -> Result<Prepared<'a>> {
let harness = opts.agent;
let base_url = match &opts.base_url {
Some(explicit) => explicit.clone(),
None => derive_base_url(&cfg.server.listen),
};
if matches!(harness.routing, crate::harness::Routing::CodexArgs) {
let conflicts = codex_forwarded_config_args(&opts.agent_args);
if !conflicts.is_empty() {
anyhow::bail!(
"codex forwarded config flags ({}) can override BitRouter's one-shot provider \
injection. Remove those -c/--config flags and run `{cli} launch --agent codex \
--check` to inspect the route before launching.",
conflicts.join(", "),
cli = invocation::name()
);
}
}
let binary = ensure_harness_installed(harness, opts.no_install).await?;
if opts.base_url.is_none() && listen_is_local(&cfg.server.listen) {
ensure_local_daemon(source, cfg, opts.no_start).await;
} else {
let target = opts
.base_url
.as_deref()
.and_then(listen_from_base_url)
.unwrap_or_else(|| cfg.server.listen.clone());
warn_if_daemon_unreachable(&target);
}
let parent_auth = match harness.routing {
crate::harness::Routing::Env { auth_env, .. } => nonempty_env(auth_env),
_ => None,
};
let token = resolve_launch_token(
parent_auth,
nonempty_env(crate::harness::BITROUTER_API_KEY_ENV),
);
let catalog = if needs_model_catalog(harness) {
fetch_daemon_models(&base_url).await
} else {
Vec::new()
};
let state_dir = launch_state_dir()?;
let gateways = launch_gateways(
cfg,
&base_url,
opts.base_url.is_none() && listen_is_local(&cfg.server.listen),
);
let overlay = harness
.launch_overlay(
&base_url,
&token,
opts.model.as_deref(),
&catalog,
&gateways,
&state_dir,
)
.with_context(|| format!("assembling the '{}' launch overlay", harness.id))?;
let launch = ChildLaunch {
env: overlay.env,
args_prefix: overlay.args,
};
let p = Palette::for_stderr();
eprintln!("{}", startup_line(harness, &base_url, &gateways, &p));
Ok(Prepared {
binary,
launch,
agent_args: opts.agent_args,
harness,
source,
launch_id: is_launch_token(&token).then(|| token.clone()),
session_start: chrono::Utc::now(),
})
}
fn startup_line(
harness: &crate::harness::Harness,
base_url: &str,
gateways: &[crate::harness::McpServer],
p: &Palette,
) -> String {
let agent_id = harness_id(harness);
let head = format!(
"{cyan}{bold}launch:{reset} {bold}{agent_id}{reset}",
cyan = p.cyan,
bold = p.bold,
reset = p.reset,
);
if matches!(harness.routing, crate::harness::Routing::OwnAuth) {
return format!("{head} · own-auth · not routed · not metered");
}
format!(
"{head} · routed via bitrouter ({base_url}) · {}",
capability_summary(harness, gateways, p)
)
}
pub(crate) fn capability_summary(
harness: &crate::harness::Harness,
gateways: &[crate::harness::McpServer],
p: &Palette,
) -> String {
let named = |name: &str| gateways.iter().any(|s| s.name == name);
if harness.injects_mcp() {
format!("tools {}", mark(named(crate::gateways::TOOLS_SERVER), p))
} else {
format!(
"tools {} ({} has no MCP mechanism)",
mark(false, p),
harness_id(harness),
)
}
}
fn mark(on: bool, p: &Palette) -> String {
if on {
format!("{}✓{}", p.green, p.reset)
} else {
format!("{}✗{}", p.dim, p.reset)
}
}
fn child_args(launch: &ChildLaunch, agent_args: &[String]) -> Vec<String> {
let mut args = launch.args_prefix.clone();
args.extend_from_slice(agent_args);
args
}
pub async fn exec_inherited(prepared: Prepared<'_>) -> Result<()> {
let Prepared {
binary,
launch,
agent_args,
harness,
source,
session_start,
launch_id,
..
} = prepared;
let agent_id = harness_id(harness);
let mut cmd = tokio::process::Command::new(&binary);
cmd.args(child_args(&launch, &agent_args));
for (k, v) in &launch.env {
cmd.env(k, v);
}
cmd.stdin(std::process::Stdio::inherit())
.stdout(std::process::Stdio::inherit())
.stderr(std::process::Stdio::inherit());
let status = cmd
.status()
.await
.with_context(|| format!("spawning agent '{agent_id}' ({})", binary.display()))?;
print_exit_summary(
source,
launch_id.as_deref(),
session_start,
&Palette::for_stderr(),
)
.await;
std::process::exit(status.code().unwrap_or(1));
}
async fn print_exit_summary(
source: &crate::paths::ConfigSource,
launch_id: Option<&str>,
session_start: chrono::DateTime<chrono::Utc>,
p: &Palette,
) {
use crate::metering::store::TimeWindow;
let Some(store) = crate::metering::reader::open_readonly(source).await else {
return;
};
let window = TimeWindow::Custom {
start: session_start,
end: chrono::Utc::now(),
};
let session = match launch_id {
Some(id) => store.spend_summary_for_launch(id, window).await,
None => store.spend_summary(window).await,
};
let (Ok(session), Ok(today)) = (session, store.spend_summary(TimeWindow::Today).await) else {
return;
};
if session.requests == 0 {
return;
}
let scope = if launch_id.is_some() {
"session spend"
} else {
"spend since launch (all callers)"
};
eprintln!(
"{cyan}{bold}launch:{reset} {scope} {bold}{}{reset} ({} requests) · today {}",
crate::metering::fmt_usd(session.spend_micro_usd),
session.requests,
crate::metering::fmt_usd(today.spend_micro_usd),
cyan = p.cyan,
bold = p.bold,
reset = p.reset,
);
}
fn launch_gateways(
cfg: &bitrouter_sdk::config::Config,
base_url: &str,
owns_local_daemon: bool,
) -> Vec<crate::harness::McpServer> {
let auth = crate::harness::resolve_gateway_auth(
std::env::var(crate::harness::BITROUTER_API_KEY_ENV).ok(),
false,
)
.unwrap_or_else(|| crate::harness::PLACEHOLDER_API_KEY.to_string());
let aggregate_route = (cfg.mcp.aggregate.enabled
&& (!owns_local_daemon || !cfg.mcp_servers.is_empty()))
.then(|| cfg.mcp.aggregate.route.clone());
crate::gateways::gateway_servers(base_url, &auth, aggregate_route.as_deref())
}
fn needs_model_catalog(h: &crate::harness::Harness) -> bool {
matches!(h.routing, crate::harness::Routing::ConfigFile(_))
}
fn launch_state_dir() -> Result<PathBuf> {
let dot_dir = std::env::current_dir()
.context("resolving the working directory for synthesized harness config")?
.join(".bitrouter");
let _ = ensure_self_ignored(&dot_dir);
Ok(dot_dir.join("launch"))
}
fn ensure_self_ignored(dot_dir: &std::path::Path) -> std::io::Result<()> {
std::fs::create_dir_all(dot_dir)?;
let gitignore = dot_dir.join(".gitignore");
if !gitignore.exists() {
std::fs::write(&gitignore, "*\n")?;
}
Ok(())
}
async fn fetch_daemon_models(base_url: &str) -> Vec<String> {
let url = format!("{}/v1/models", bitrouter_root_url(base_url));
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
{
Ok(client) => client,
Err(_) => return Vec::new(),
};
let Ok(resp) = client.get(&url).send().await else {
return Vec::new();
};
let Ok(body) = resp.json::<serde_json::Value>().await else {
return Vec::new();
};
body["data"]
.as_array()
.map(|models| {
models
.iter()
.filter_map(|m| m["id"].as_str().map(str::to_string))
.collect()
})
.unwrap_or_default()
}
pub async fn check(
cfg: &bitrouter_sdk::config::Config,
opts: &SpawnOptions,
) -> Result<SpawnCheckReport> {
let harness = opts.agent;
let agent_id = harness_id(harness);
let binary = harness.interactive_binary.unwrap_or(harness.id);
let is_codex = matches!(harness.routing, crate::harness::Routing::CodexArgs);
let base_url = opts
.base_url
.clone()
.unwrap_or_else(|| derive_base_url(&cfg.server.listen));
let model = opts.model.clone().or_else(|| {
is_codex
.then(|| codex_requested_model(&opts.agent_args))
.flatten()
});
let mut checks = Vec::new();
checks.push(match resolve_binary(binary) {
Some(path) => SpawnCheckRow {
name: "agent binary".to_string(),
status: SpawnCheckStatus::Pass,
message: format!("found {}", path.display()),
},
None => SpawnCheckRow {
name: "agent binary".to_string(),
status: SpawnCheckStatus::Fail,
message: format!("{binary} is not on PATH"),
},
});
checks.push(SpawnCheckRow {
name: "routing".to_string(),
status: if matches!(harness.routing, crate::harness::Routing::OwnAuth) {
SpawnCheckStatus::Warn
} else {
SpawnCheckStatus::Pass
},
message: match harness.routing {
crate::harness::Routing::OwnAuth => format!(
"'{}' is a subscription client the daemon borrows sessions from — it launches \
with its own auth, never routed through BitRouter",
harness.id
),
_ if harness.env_args_routable() => {
format!("via daemon {base_url} [{}] by env/args", harness.id)
}
_ => format!(
"via daemon {base_url} [{}] by config synthesized under .bitrouter/launch",
harness.id
),
},
});
checks.push(check_base_url(&base_url).await);
if is_codex {
let conflicts = codex_forwarded_config_args(&opts.agent_args);
checks.push(if conflicts.is_empty() {
SpawnCheckRow {
name: "codex config overrides".to_string(),
status: SpawnCheckStatus::Pass,
message: "no forwarded -c/--config flags detected".to_string(),
}
} else {
SpawnCheckRow {
name: "codex config overrides".to_string(),
status: SpawnCheckStatus::Fail,
message: format!(
"forwarded {} can override BitRouter's provider injection",
conflicts.join(", ")
),
}
});
checks.push(match &model {
Some(model) => {
let mut resolved = cfg.clone();
crate::assemble::merge_registry_into(&mut resolved).await;
let resolved = crate::commands::resolve_static(resolved);
let route = crate::codex_router::subscription_route(&resolved, model);
codex_route_check(
model,
crate::commands::resolve_route(&resolved, route.as_deref().unwrap_or(model))
.await,
)
}
None => SpawnCheckRow {
name: "codex model route".to_string(),
status: SpawnCheckStatus::Warn,
message: "no --model given or forwarded; Codex will choose its default model"
.to_string(),
},
});
}
Ok(SpawnCheckReport {
agent: agent_id.to_string(),
base_url,
model,
checks,
})
}
async fn check_base_url(base_url: &str) -> SpawnCheckRow {
let health = format!("{}/health", bitrouter_root_url(base_url));
match base_url_health(base_url).await {
HealthProbe::Ok(status) => SpawnCheckRow {
name: "bitrouter base url".to_string(),
status: SpawnCheckStatus::Pass,
message: format!("{health} responded {status}"),
},
HealthProbe::BadStatus(status) => SpawnCheckRow {
name: "bitrouter base url".to_string(),
status: SpawnCheckStatus::Fail,
message: format!("{health} responded {status}"),
},
HealthProbe::Unreachable(e) => SpawnCheckRow {
name: "bitrouter base url".to_string(),
status: SpawnCheckStatus::Fail,
message: format!("could not reach {health}: {e}"),
},
}
}
enum HealthProbe {
Ok(reqwest::StatusCode),
BadStatus(reqwest::StatusCode),
Unreachable(String),
}
async fn base_url_health(base_url: &str) -> HealthProbe {
let health = format!("{}/health", bitrouter_root_url(base_url));
let client = match reqwest::Client::builder()
.timeout(Duration::from_secs(2))
.build()
{
Ok(client) => client,
Err(e) => return HealthProbe::Unreachable(format!("could not build HTTP client: {e}")),
};
match client.get(&health).send().await {
Ok(resp) if resp.status().is_success() => HealthProbe::Ok(resp.status()),
Ok(resp) => HealthProbe::BadStatus(resp.status()),
Err(e) => HealthProbe::Unreachable(e.to_string()),
}
}
pub(crate) async fn base_url_reachable(base_url: &str) -> bool {
matches!(base_url_health(base_url).await, HealthProbe::Ok(_))
}
fn bitrouter_root_url(base_url: &str) -> String {
let trimmed = base_url.trim_end_matches('/');
trimmed.strip_suffix("/v1").unwrap_or(trimmed).to_string()
}
fn codex_requested_model(args: &[String]) -> Option<String> {
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if arg == "--model" || arg == "-m" {
return iter.next().filter(|v| !v.is_empty()).cloned();
}
if let Some(value) = arg.strip_prefix("--model=")
&& !value.is_empty()
{
return Some(value.to_string());
}
}
None
}
fn codex_forwarded_config_args(args: &[String]) -> Vec<&'static str> {
args.iter()
.filter_map(|arg| match arg.as_str() {
"-c" => Some("-c"),
"--config" => Some("--config"),
s if s.starts_with("--config=") => Some("--config"),
_ => None,
})
.collect()
}
fn codex_route_check(model: &str, route: Result<Vec<crate::daemon::RouteHop>>) -> SpawnCheckRow {
match route {
Ok(chain) if chain.is_empty() => SpawnCheckRow {
name: "codex model route".to_string(),
status: SpawnCheckStatus::Fail,
message: format!("{model} resolved to an empty route chain"),
},
Ok(chain) => {
let providers = chain
.iter()
.map(|hop| format!("{}:{} ({})", hop.provider, hop.service_id, hop.api_protocol))
.collect::<Vec<_>>()
.join(" -> ");
if chain
.iter()
.any(|hop| hop.api_protocol.eq_ignore_ascii_case("responses"))
{
SpawnCheckRow {
name: "codex model route".to_string(),
status: SpawnCheckStatus::Pass,
message: format!("{model} can route through Responses: {providers}"),
}
} else {
SpawnCheckRow {
name: "codex model route".to_string(),
status: SpawnCheckStatus::Fail,
message: format!(
"{model} has no responses-compatible endpoint for Codex: {providers}"
),
}
}
}
Err(e) => SpawnCheckRow {
name: "codex model route".to_string(),
status: SpawnCheckStatus::Fail,
message: format!("could not resolve {model}: {e:#}"),
},
}
}
pub(crate) fn nonempty_env(name: &str) -> Option<String> {
std::env::var(name).ok().filter(|v| !v.is_empty())
}
pub(crate) fn derive_base_url(listen: &str) -> String {
let (host, port) = split_listen(listen);
format!("http://{}:{}", rewrite_host(host), port)
}
const DEFAULT_PORT: &str = "4356";
fn split_listen(listen: &str) -> (&str, &str) {
if listen.starts_with('[') {
return match listen.rsplit_once("]:") {
Some((host, port)) => (&listen[..host.len() + 1], port),
None => (listen, DEFAULT_PORT),
};
}
match listen.rsplit_once(':') {
Some((host, port)) => (host, port),
None => (listen, DEFAULT_PORT),
}
}
fn rewrite_host(host: &str) -> &str {
match host {
"0.0.0.0" | "" => "127.0.0.1",
"::" | "[::]" => "[::1]",
other => other,
}
}
pub(crate) fn listen_is_local(listen: &str) -> bool {
let (host, _port) = split_listen(listen);
matches!(
host,
"127.0.0.1" | "0.0.0.0" | "" | "::1" | "[::1]" | "::" | "[::]" | "localhost"
)
}
pub(crate) fn listen_from_base_url(url: &str) -> Option<String> {
let rest = url
.strip_prefix("http://")
.or_else(|| url.strip_prefix("https://"))
.unwrap_or(url);
let authority = rest.split('/').next().unwrap_or(rest);
(!authority.is_empty()).then(|| authority.to_string())
}
pub(crate) async fn ensure_local_daemon(
source: &crate::paths::ConfigSource,
cfg: &bitrouter_sdk::config::Config,
no_start: bool,
) {
let socket = crate::daemon::socket_path_for(source, cfg);
match crate::daemon::probe_status(&socket).await {
Ok(Some(_)) => {}
Ok(None) => {
let p = Palette::for_stderr();
if no_start {
warn_if_daemon_unreachable(&cfg.server.listen);
return;
}
eprintln!(
"{cyan}note:{reset} no BitRouter daemon is running — starting one…",
cyan = p.cyan,
reset = p.reset,
);
let log_path = source.home().join("bitrouter.log");
match crate::daemon::start_and_wait(
source,
&log_path,
Some(&socket),
crate::daemon::DAEMON_READY_TIMEOUT,
)
.await
{
Ok(crate::daemon::DaemonStartOutcome::Ready(info)) => {
eprintln!(
"{cyan}note:{reset} BitRouter daemon ready (pid {})",
info.pid,
cyan = p.cyan,
reset = p.reset,
);
}
Ok(crate::daemon::DaemonStartOutcome::NotReadyInTime { pid }) => {
eprintln!(
"{cyan}note:{reset} daemon started (pid {pid}) but is not ready yet — \
the agent may need a moment; logs at {}",
log_path.display(),
cyan = p.cyan,
reset = p.reset,
);
}
Ok(crate::daemon::DaemonStartOutcome::Exited { status, log_tail }) => {
eprintln!(
"{cyan}note:{reset} daemon exited during startup ({status}) — \
launching the agent anyway",
cyan = p.cyan,
reset = p.reset,
);
crate::daemon::eprint_failure_log(&log_path, &log_tail);
}
Err(e) => {
eprintln!(
"{cyan}note:{reset} could not start the daemon ({e:#}) — \
launching the agent anyway",
cyan = p.cyan,
reset = p.reset,
);
}
}
}
Err(e) => {
tracing::debug!(error = %e, "daemon status probe errored; assuming up");
}
}
}
pub(crate) fn resolve_binary(name: &str) -> Option<PathBuf> {
find_on_path(name, std::env::var_os("PATH"), &extra_search_dirs())
}
fn find_on_path(name: &str, path: Option<OsString>, extra: &[PathBuf]) -> Option<PathBuf> {
let mut dirs: Vec<PathBuf> = Vec::new();
if let Some(path) = path {
dirs.extend(std::env::split_paths(&path));
}
dirs.extend(extra.iter().cloned());
for dir in dirs {
let candidate = dir.join(name);
if is_executable_file(&candidate) {
return Some(candidate);
}
#[cfg(windows)]
{
for ext in ["exe", "cmd", "bat"] {
let with_ext = dir.join(format!("{name}.{ext}"));
if is_executable_file(&with_ext) {
return Some(with_ext);
}
}
}
}
None
}
fn extra_search_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
if let Some(home) = home_dir() {
dirs.push(home.join(".local").join("bin"));
}
dirs
}
fn is_executable_file(path: &std::path::Path) -> bool {
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
if !meta.is_file() {
return false;
}
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
meta.permissions().mode() & 0o111 != 0
}
#[cfg(not(unix))]
{
true
}
}
fn home_dir() -> Option<PathBuf> {
#[cfg(windows)]
{
std::env::var_os("USERPROFILE").map(PathBuf::from)
}
#[cfg(not(windows))]
{
std::env::var_os("HOME").map(PathBuf::from)
}
}
pub(crate) async fn ensure_agent_installed(agent: SpawnAgent, no_install: bool) -> Result<PathBuf> {
let spec = agent.spec();
let harness = crate::harness::by_interactive_binary(spec.binary).ok_or_else(|| {
anyhow::anyhow!(
"no catalog harness for interactive binary '{}'",
spec.binary
)
})?;
ensure_harness_installed(harness, no_install).await
}
pub(crate) async fn ensure_harness_installed(
harness: &'static crate::harness::Harness,
no_install: bool,
) -> Result<PathBuf> {
let binary = harness
.interactive_binary
.ok_or_else(|| anyhow::anyhow!("harness '{}' has no interactive binary", harness.id))?;
match resolve_binary(binary) {
Some(path) => Ok(path),
None => ensure_installed(harness, binary, no_install).await,
}
}
async fn ensure_installed(
harness: &'static crate::harness::Harness,
binary: &'static str,
no_install: bool,
) -> Result<PathBuf> {
let Some(install) = InstallCommand::for_binary(binary) else {
anyhow::bail!(
"agent '{binary}' is not installed (no `{binary}` on PATH).\n \
BitRouter bundles no installer for it — install it from {}",
harness.project_url,
);
};
let may_prompt = !no_install && std::io::stdin().is_terminal();
if !may_prompt {
anyhow::bail!(
"agent '{binary}' is not installed (no `{binary}` on PATH).\n Install it with:\n {}",
install.display(),
);
}
if !confirm_install(binary, &install)? {
anyhow::bail!("aborted — '{binary}' was not installed");
}
install.run().await?;
resolve_binary(binary).ok_or_else(|| {
anyhow::anyhow!(
"installed '{binary}' but still cannot find `{binary}` on PATH or in ~/.local/bin — \
open a new shell (or add the install dir to PATH) and re-run",
)
})
}
fn confirm_install(binary: &str, install: &InstallCommand) -> Result<bool> {
use std::io::{BufRead, Write};
let p = Palette::for_stderr();
eprintln!(
"{cyan}{bold}info:{reset} agent `{binary}` is not installed on this machine.",
cyan = p.cyan,
bold = p.bold,
reset = p.reset,
);
eprintln!(" Installer: {}", install.display());
eprint!("Proceed to install? [Y/n]: ");
std::io::stderr().flush().ok();
let stdin = std::io::stdin();
let mut line = String::new();
let n = stdin
.lock()
.read_line(&mut line)
.context("reading install confirmation from stdin")?;
if n == 0 {
eprintln!();
return Ok(false);
}
let answer = line.trim().to_ascii_lowercase();
Ok(answer.is_empty() || answer == "y" || answer == "yes")
}
fn warn_if_daemon_unreachable(listen: &str) {
use std::net::{TcpStream, ToSocketAddrs};
use std::time::Duration;
let (host, port) = split_listen(listen);
let probe = format!("{}:{}", rewrite_host(host), port);
let reachable = probe
.to_socket_addrs()
.ok()
.and_then(|mut addrs| addrs.next())
.map(|addr| TcpStream::connect_timeout(&addr, Duration::from_millis(300)).is_ok())
.unwrap_or(false);
if !reachable {
let p = Palette::for_stderr();
eprintln!(
"{cyan}note:{reset} no BitRouter daemon appears to be listening on {probe} — \
start one with `{cli} start` (the agent will fail to reach it otherwise).",
cyan = p.cyan,
reset = p.reset,
cli = invocation::name(),
);
}
}
#[derive(Debug, Clone)]
pub struct InstallCommand {
program: &'static str,
args: Vec<String>,
human: String,
}
impl InstallCommand {
pub fn for_binary(binary: &str) -> Option<Self> {
match binary {
"claude" => Some(Self::claude()),
"codex" => Some(Self::codex()),
_ => None,
}
}
#[cfg(not(windows))]
fn claude() -> Self {
let human = "curl -fsSL https://claude.ai/install.sh | bash".to_string();
Self {
program: "bash",
args: vec![
"-c".to_string(),
"curl -fsSL https://claude.ai/install.sh | bash".to_string(),
],
human,
}
}
#[cfg(windows)]
fn claude() -> Self {
let human = "irm https://claude.ai/install.ps1 | iex".to_string();
Self {
program: "powershell",
args: vec![
"-NoProfile".to_string(),
"-Command".to_string(),
"irm https://claude.ai/install.ps1 | iex".to_string(),
],
human,
}
}
#[cfg(not(windows))]
fn codex() -> Self {
let human = "curl -fsSL https://chatgpt.com/codex/install.sh | sh".to_string();
Self {
program: "sh",
args: vec![
"-c".to_string(),
"curl -fsSL https://chatgpt.com/codex/install.sh | sh".to_string(),
],
human,
}
}
#[cfg(windows)]
fn codex() -> Self {
let human =
r#"powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex""#
.to_string();
Self {
program: "powershell",
args: vec![
"-ExecutionPolicy".to_string(),
"ByPass".to_string(),
"-c".to_string(),
"irm https://chatgpt.com/codex/install.ps1 | iex".to_string(),
],
human,
}
}
pub fn display(&self) -> &str {
&self.human
}
async fn run(&self) -> Result<()> {
let p = Palette::for_stderr();
eprintln!(
"{cyan}{bold}info:{reset} installing — {}",
self.human,
cyan = p.cyan,
bold = p.bold,
reset = p.reset,
);
let status = tokio::process::Command::new(self.program)
.args(&self.args)
.status()
.await
.with_context(|| format!("running installer: {}", self.human))?;
if !status.success() {
anyhow::bail!("installer exited with {status}: {}", self.human);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn dot_dir_is_created_self_ignoring() {
let base = tempfile::tempdir().expect("tempdir");
let dot = base.path().join(".bitrouter");
ensure_self_ignored(&dot).expect("ensure");
assert_eq!(
std::fs::read_to_string(dot.join(".gitignore")).expect("read"),
"*\n"
);
}
#[test]
fn dot_dir_never_overwrites_an_existing_gitignore() {
let base = tempfile::tempdir().expect("tempdir");
let dot = base.path().join(".bitrouter");
std::fs::create_dir_all(&dot).expect("mkdir");
std::fs::write(dot.join(".gitignore"), "launch/\n").expect("write");
ensure_self_ignored(&dot).expect("ensure");
assert_eq!(
std::fs::read_to_string(dot.join(".gitignore")).expect("read"),
"launch/\n",
"a user-authored ignore file is preserved"
);
}
#[test]
fn child_args_put_the_routing_prefix_before_forwarded_args() {
let launch = ChildLaunch {
env: vec![],
args_prefix: vec!["-c".into(), "model=x".into()],
};
let forwarded = vec!["--search".to_string(), "-m".to_string(), "mine".to_string()];
assert_eq!(
child_args(&launch, &forwarded),
["-c", "model=x", "--search", "-m", "mine"]
);
assert_eq!(
child_args(
&ChildLaunch {
env: vec![],
args_prefix: vec![]
},
&forwarded
),
forwarded
);
assert!(
child_args(
&ChildLaunch {
env: vec![],
args_prefix: vec![]
},
&[]
)
.is_empty()
);
}
#[test]
fn startup_line_states_routing_and_tool_availability() -> anyhow::Result<()> {
let p = Palette::none();
let cfg = config_with_mcp_upstream()?;
let gateways = launch_gateways(&cfg, "http://127.0.0.1:4356", true);
let line = |id: &str| {
startup_line(
crate::harness::by_id(id).expect("catalog"),
"http://127.0.0.1:4356",
&gateways,
&p,
)
};
let claude = line("claude-acp");
assert!(claude.contains("routed via bitrouter"), "{claude}");
assert!(claude.contains("tools ✓"), "{claude}");
let pi = line("pi-acp");
assert!(pi.contains("routed via bitrouter"), "{pi}");
assert!(pi.contains("tools ✗"), "{pi}");
assert!(pi.contains("no MCP mechanism"), "{pi}");
Ok(())
}
#[test]
fn startup_line_reports_a_disabled_aggregate_as_a_missing_tools_gateway() {
let p = Palette::none();
let line = startup_line(
crate::harness::by_id("claude-acp").expect("catalog"),
"http://127.0.0.1:4356",
&[],
&p,
);
assert!(line.contains("tools ✗"), "{line}");
}
fn config_with_mcp_upstream() -> anyhow::Result<bitrouter_sdk::config::Config> {
let mut cfg = bitrouter_sdk::config::Config::default();
cfg.mcp_servers.insert(
"fixture".into(),
serde_json::from_value(serde_json::json!({
"name":"fixture", "transport":{"type":"http", "url":"http://127.0.0.1:4357/mcp"}
}))?,
);
Ok(cfg)
}
#[test]
fn launch_wires_the_tools_gateway_into_the_overlay() -> anyhow::Result<()> {
let cfg = config_with_mcp_upstream()?;
let servers = launch_gateways(&cfg, "http://127.0.0.1:4356", true);
let names: Vec<&str> = servers.iter().map(|s| s.name.as_str()).collect();
assert_eq!(names, ["bitrouter_tools"]);
assert!(!names.contains(&"bitrouter_fleet"));
for id in ["claude-acp", "codex-acp", "opencode", "hermes-acp"] {
let h = crate::harness::by_id(id).expect("catalog entry");
let dir = tempfile::tempdir().expect("tempdir");
let overlay = h
.launch_overlay(
"http://127.0.0.1:4356",
"tok",
None,
&[],
&servers,
dir.path(),
)
.expect("overlay");
let mut rendered = overlay.args.join(" ");
let paths = overlay
.args
.iter()
.chain(overlay.env.iter().map(|(_, v)| v));
for path in paths {
let path = std::path::Path::new(path);
for candidate in [path.to_path_buf(), path.join("config.yaml")] {
rendered.push_str(&std::fs::read_to_string(candidate).unwrap_or_default());
}
}
assert!(
rendered.contains("bitrouter_tools"),
"{id}: gateway `bitrouter_tools` never reached the harness"
);
assert!(!rendered.contains("bitrouter_skills"));
}
Ok(())
}
#[test]
fn launch_gateways_drops_tools_when_the_aggregate_is_disabled() {
let mut cfg = bitrouter_sdk::config::Config::default();
cfg.mcp.aggregate.enabled = false;
let servers = launch_gateways(&cfg, "http://127.0.0.1:4356", true);
let names: Vec<&str> = servers.iter().map(|s| s.name.as_str()).collect();
assert!(names.is_empty());
}
#[test]
fn launch_without_mcp_upstreams_does_not_advertise_an_unmounted_gateway() {
let cfg = bitrouter_sdk::config::Config::default();
assert!(cfg.mcp_servers.is_empty());
assert!(launch_gateways(&cfg, "http://127.0.0.1:4356", true).is_empty());
}
#[test]
fn external_launch_keeps_mcp_gateway_without_local_upstreams() {
let mut cfg = bitrouter_sdk::config::Config::default();
assert!(cfg.mcp_servers.is_empty());
let servers = launch_gateways(&cfg, "https://router.example", false);
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].name, "bitrouter_tools");
cfg.mcp.aggregate.enabled = false;
assert!(launch_gateways(&cfg, "https://router.example", false).is_empty());
}
#[test]
fn listen_is_local_classifies_loopback_and_wildcard() {
for local in [
"127.0.0.1:4356",
"0.0.0.0:4356",
"[::1]:4356",
"[::]:4356",
"localhost:4356",
"127.0.0.1",
] {
assert!(listen_is_local(local), "{local} should be local");
}
for remote in [
"router.internal:8080",
"192.168.1.5:4356",
"10.0.0.3:4356",
"example.com:443",
] {
assert!(!listen_is_local(remote), "{remote} should be remote");
}
}
#[test]
fn listen_from_base_url_extracts_authority() {
assert_eq!(
listen_from_base_url("http://127.0.0.1:4356").as_deref(),
Some("127.0.0.1:4356")
);
assert_eq!(
listen_from_base_url("https://router.example.com/v1").as_deref(),
Some("router.example.com")
);
assert_eq!(
listen_from_base_url("127.0.0.1:4356").as_deref(),
Some("127.0.0.1:4356")
);
assert_eq!(listen_from_base_url(""), None);
}
#[test]
fn base_url_rewrites_wildcard_bind_to_loopback() {
assert_eq!(derive_base_url("0.0.0.0:4356"), "http://127.0.0.1:4356");
assert_eq!(derive_base_url("[::]:4356"), "http://[::1]:4356");
}
#[test]
fn base_url_preserves_explicit_host() {
assert_eq!(derive_base_url("127.0.0.1:4356"), "http://127.0.0.1:4356");
assert_eq!(
derive_base_url("router.internal:8080"),
"http://router.internal:8080"
);
assert_eq!(derive_base_url("[::1]:9000"), "http://[::1]:9000");
}
#[test]
fn base_url_defaults_port_when_missing() {
assert_eq!(derive_base_url("127.0.0.1"), "http://127.0.0.1:4356");
assert_eq!(derive_base_url("[::1]"), "http://[::1]:4356");
}
#[test]
fn launch_token_precedence() {
assert_eq!(
resolve_launch_token(Some("user-token".into()), Some("brk_key".into())),
"user-token"
);
assert_eq!(
resolve_launch_token(None, Some("brk_key".into())),
"brk_key"
);
let minted = resolve_launch_token(None, None);
assert!(is_launch_token(&minted), "{minted}");
}
#[test]
fn only_a_minted_credential_is_treated_as_attribution() {
assert!(!is_launch_token("user-token"));
assert!(!is_launch_token("brk_key"));
assert!(!is_launch_token(crate::harness::PLACEHOLDER_API_KEY));
assert!(is_launch_token(&mint_launch_token()));
}
#[test]
fn every_launch_mints_a_distinct_token() {
let a = mint_launch_token();
let b = mint_launch_token();
assert_ne!(a, b);
}
#[test]
fn launch_agents_map_to_catalog_harnesses() {
let claude = crate::harness::by_interactive_binary(SpawnAgent::Claude.spec().binary)
.expect("claude maps to a catalog harness");
assert_eq!(claude.id, "claude-acp");
assert!(matches!(
claude.routing,
crate::harness::Routing::Env {
base_url_env: "ANTHROPIC_BASE_URL",
auth_env: "ANTHROPIC_AUTH_TOKEN",
..
}
));
let codex = crate::harness::by_interactive_binary(SpawnAgent::Codex.spec().binary)
.expect("codex maps to a catalog harness");
assert_eq!(codex.id, "codex-acp");
assert!(matches!(codex.routing, crate::harness::Routing::CodexArgs));
}
#[test]
fn resolve_launch_agent_accepts_every_supported_harness() {
for h in crate::harness::CATALOG
.iter()
.filter(|h| h.launch_supported())
{
let binary = h.interactive_binary.expect("supported implies a binary");
let resolved = resolve_launch_agent(binary).expect("resolves by binary");
assert_eq!(resolved.id, h.id, "{binary}");
let by_id = resolve_launch_agent(h.id).expect("resolves by catalog id");
assert_eq!(by_id.id, h.id);
}
assert_eq!(
launchable(),
vec!["claude", "codex", "hermes", "openclaw", "opencode", "pi"]
);
}
#[test]
fn resolve_launch_agent_rejects_adapter_only_and_unknown_ids() {
let err = resolve_launch_agent("gemini-cli").expect_err("not launchable");
assert!(err.to_string().contains("gemini-cli"));
let err = resolve_launch_agent("nope").expect_err("unknown id");
let msg = err.to_string();
assert!(msg.contains("is not a launchable harness"), "{msg}");
for id in ["claude", "codex", "opencode", "pi", "hermes", "openclaw"] {
assert!(msg.contains(id), "{msg} should list {id}");
}
for bad in ["gemini-cli", "nope"] {
let err = resolve_launch_agent(bad).expect_err("rejected");
assert_eq!(
crate::output::error::envelope_from_anyhow(&err).error.kind,
bitrouter_sdk::error::ErrorKind::BadRequest,
"{bad}"
);
}
}
#[test]
fn needs_model_catalog_only_for_config_synthesis_harnesses() {
for id in ["opencode", "pi-acp", "hermes-acp", "openclaw"] {
let h = crate::harness::by_id(id).expect("catalog harness");
assert!(needs_model_catalog(h), "{id} synthesizes a model list");
}
for id in ["claude-acp", "codex-acp"] {
let h = crate::harness::by_id(id).expect("catalog harness");
assert!(!needs_model_catalog(h), "{id} needs no /v1/models probe");
}
}
#[test]
fn codex_spec_uses_codex_binary() {
let spec = SpawnAgent::Codex.spec();
assert_eq!(spec.binary, "codex");
assert_eq!(spec.id, "codex");
}
#[test]
fn codex_requested_model_reads_forwarded_model_args() {
assert_eq!(
codex_requested_model(&[
"exec".to_string(),
"--model".to_string(),
"gpt-5.5".to_string()
])
.as_deref(),
Some("gpt-5.5")
);
assert_eq!(
codex_requested_model(&[
"exec".to_string(),
"-m".to_string(),
"local-model".to_string()
])
.as_deref(),
Some("local-model")
);
assert_eq!(codex_requested_model(&["exec".to_string()]), None);
}
#[test]
fn codex_forwarded_config_args_are_flagged_before_launch() {
let conflicts = codex_forwarded_config_args(&[
"exec".to_string(),
"-c".to_string(),
"foo=1".to_string(),
]);
assert_eq!(conflicts, vec!["-c"]);
let conflicts = codex_forwarded_config_args(&[
"exec".to_string(),
"--config".to_string(),
"model_provider=\"openai\"".to_string(),
]);
assert_eq!(conflicts, vec!["--config"]);
}
#[test]
fn bitrouter_root_url_strips_v1_for_preflight_health_probe() {
assert_eq!(
bitrouter_root_url("http://127.0.0.1:4356/v1"),
"http://127.0.0.1:4356"
);
assert_eq!(
bitrouter_root_url("http://127.0.0.1:4356"),
"http://127.0.0.1:4356"
);
}
#[test]
fn codex_route_check_accepts_any_responses_provider() {
let route = vec![crate::daemon::RouteHop {
provider: "openai".to_string(),
service_id: "gpt-5.5".to_string(),
api_protocol: "responses".to_string(),
}];
let check = codex_route_check("gpt-5.5", Ok(route));
assert_eq!(check.status, SpawnCheckStatus::Pass);
assert!(check.message.contains("openai"));
}
#[tokio::test]
async fn codex_preflight_resolves_native_subscription_model() -> anyhow::Result<()> {
let cfg = bitrouter_sdk::config::parse_with(
r#"
inherit_defaults: false
providers:
openai-codex:
class: first-party-subscription
models:
- id: openai/gpt-native
provider_model_id: gpt-native
api_protocol: responses
"#,
|_| None,
)?;
let report = check(
&cfg,
&SpawnOptions {
agent: crate::harness::by_id("codex-acp").context("Codex harness missing")?,
model: Some("gpt-native".into()),
agent_args: Vec::new(),
base_url: Some("http://127.0.0.1:0".into()),
no_install: true,
no_start: true,
check: true,
},
)
.await?;
let route = report
.checks
.iter()
.find(|row| row.name == "codex model route")
.context("model route check missing")?;
assert_eq!(route.status, SpawnCheckStatus::Pass, "{}", route.message);
assert!(route.message.contains("openai-codex:gpt-native"));
Ok(())
}
#[test]
fn codex_route_check_rejects_non_responses_provider() {
let route = vec![crate::daemon::RouteHop {
provider: "anthropic".to_string(),
service_id: "claude-sonnet".to_string(),
api_protocol: "messages".to_string(),
}];
let check = codex_route_check("claude-sonnet", Ok(route));
assert_eq!(check.status, SpawnCheckStatus::Fail);
assert!(check.message.contains("responses"));
}
#[test]
fn install_command_is_the_official_native_installer() {
let cmd = InstallCommand::for_binary("claude").expect("claude has a bundled installer");
assert!(cmd.display().contains("claude.ai/install"));
#[cfg(not(windows))]
{
assert!(cmd.display().contains("install.sh"));
assert!(cmd.display().contains("| bash"));
}
#[cfg(windows)]
{
assert!(cmd.display().contains("install.ps1"));
}
let codex = InstallCommand::for_binary("codex").expect("codex has a bundled installer");
assert!(codex.display().contains("chatgpt.com/codex/install"));
assert!(InstallCommand::for_binary("opencode").is_none());
#[cfg(not(windows))]
{
assert!(codex.display().contains("install.sh"));
assert!(codex.display().contains("| sh"));
}
#[cfg(windows)]
{
assert!(codex.display().contains("install.ps1"));
}
}
#[test]
fn find_on_path_locates_executable_in_listed_dir() {
let dir = std::env::temp_dir().join(format!("bitrouter-spawn-path-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let bin = dir.join("fake-agent");
std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let path = std::env::join_paths([dir.as_os_str()]).unwrap();
let found = find_on_path("fake-agent", Some(path), &[]);
assert_eq!(found.as_deref(), Some(bin.as_path()));
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn find_on_path_returns_none_when_absent() {
let path = std::env::join_paths([std::env::temp_dir().as_os_str()]).unwrap();
assert!(find_on_path("definitely-not-a-real-binary-xyz", Some(path), &[]).is_none());
}
#[test]
fn find_on_path_falls_back_to_extra_dirs() {
let dir =
std::env::temp_dir().join(format!("bitrouter-spawn-extra-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let bin = dir.join("fake-agent");
std::fs::write(&bin, b"#!/bin/sh\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
}
let found = find_on_path("fake-agent", None, std::slice::from_ref(&dir));
assert_eq!(found.as_deref(), Some(bin.as_path()));
let _ = std::fs::remove_dir_all(&dir);
}
}