use std::ffi::OsString;
use std::io::IsTerminal;
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Context, Result};
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 {
agent: self,
id: "claude",
binary: "claude",
},
SpawnAgent::Codex => AgentSpec {
agent: self,
id: "codex",
binary: "codex",
},
}
}
}
#[derive(Debug, Clone, Copy)]
pub struct AgentSpec {
pub agent: SpawnAgent,
pub id: &'static str,
pub binary: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ChildLaunch {
env: Vec<(String, String)>,
args_prefix: Vec<String>,
}
fn resolve_launch_token(parent_auth: Option<String>, bitrouter_key: Option<String>) -> String {
parent_auth
.or(bitrouter_key)
.unwrap_or_else(|| crate::harness::PLACEHOLDER_API_KEY.to_string())
}
pub struct SpawnOptions {
pub agent: SpawnAgent,
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!(
"spawn check 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 async fn run(
source: &crate::paths::ConfigSource,
cfg: &bitrouter_sdk::config::Config,
opts: SpawnOptions,
) -> Result<()> {
let spec = opts.agent.spec();
let base_url = match &opts.base_url {
Some(explicit) => explicit.clone(),
None => derive_base_url(&cfg.server.listen),
};
if opts.agent == SpawnAgent::Codex {
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 `bitrouter spawn --agent \
codex --check` to inspect the route before launching.",
conflicts.join(", ")
);
}
}
let binary = ensure_agent_installed(opts.agent, 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 harness = crate::harness::by_interactive_binary(spec.binary).ok_or_else(|| {
anyhow::anyhow!(
"no catalog harness for interactive binary '{}'",
spec.binary
)
})?;
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 overlay = harness.routing_overlay(&base_url, &token, None);
let launch = ChildLaunch {
env: overlay.env,
args_prefix: overlay.args,
};
let p = Palette::for_stderr();
eprintln!(
"{cyan}{bold}spawn:{reset} launching {bold}{}{reset} via BitRouter ({})",
spec.id,
base_url,
cyan = p.cyan,
bold = p.bold,
reset = p.reset,
);
let mut cmd = tokio::process::Command::new(&binary);
cmd.args(&launch.args_prefix);
cmd.args(&opts.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 session_start = chrono::Utc::now();
let status = cmd
.status()
.await
.with_context(|| format!("spawning agent '{}' ({})", spec.id, binary.display()))?;
print_exit_summary(source, session_start, &p).await;
std::process::exit(status.code().unwrap_or(1));
}
async fn print_exit_summary(
source: &crate::paths::ConfigSource,
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 (Ok(session), Ok(today)) = (
store.spend_summary(window).await,
store.spend_summary(TimeWindow::Today).await,
) else {
return;
};
if session.requests == 0 {
return;
}
eprintln!(
"{cyan}{bold}spawn:{reset} session spend {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,
);
}
pub async fn check(
cfg: &bitrouter_sdk::config::Config,
opts: &SpawnOptions,
) -> Result<SpawnCheckReport> {
let spec = opts.agent.spec();
let base_url = opts
.base_url
.clone()
.unwrap_or_else(|| derive_base_url(&cfg.server.listen));
let model = (opts.agent == SpawnAgent::Codex)
.then(|| codex_requested_model(&opts.agent_args))
.flatten();
let mut checks = Vec::new();
checks.push(match resolve_binary(spec.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!("{} is not on PATH", spec.binary),
},
});
checks.push(check_base_url(&base_url).await);
if opts.agent == SpawnAgent::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) => {
codex_route_check(model, crate::commands::resolve_route(cfg, model).await)
}
None => SpawnCheckRow {
name: "codex model route".to_string(),
status: SpawnCheckStatus::Warn,
message: "no --model/-m forwarded; Codex will choose its default model".to_string(),
},
});
}
Ok(SpawnCheckReport {
agent: spec.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");
}
}
}
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();
match resolve_binary(spec.binary) {
Some(path) => Ok(path),
None => ensure_installed(&spec, no_install).await,
}
}
async fn ensure_installed(spec: &AgentSpec, no_install: bool) -> Result<PathBuf> {
let install = InstallCommand::for_agent(spec.agent);
let may_prompt = !no_install && std::io::stdin().is_terminal();
if !may_prompt {
anyhow::bail!(
"agent '{}' is not installed (no `{}` on PATH).\n Install it with:\n {}",
spec.id,
spec.binary,
install.display(),
);
}
if !confirm_install(spec, &install)? {
anyhow::bail!("aborted — '{}' was not installed", spec.id);
}
install.run().await?;
resolve_binary(spec.binary).ok_or_else(|| {
anyhow::anyhow!(
"installed '{}' but still cannot find `{}` on PATH or in ~/.local/bin — \
open a new shell (or add the install dir to PATH) and re-run",
spec.id,
spec.binary,
)
})
}
fn confirm_install(spec: &AgentSpec, install: &InstallCommand) -> Result<bool> {
use std::io::{BufRead, Write};
let p = Palette::for_stderr();
eprintln!(
"{cyan}{bold}info:{reset} agent `{}` is not installed on this machine.",
spec.id,
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 `bitrouter start` (the agent will fail to reach it otherwise).",
cyan = p.cyan,
reset = p.reset,
);
}
}
#[derive(Debug, Clone)]
pub struct InstallCommand {
program: &'static str,
args: Vec<String>,
human: String,
}
impl InstallCommand {
pub fn for_agent(agent: SpawnAgent) -> Self {
match agent {
SpawnAgent::Claude => Self::claude(),
SpawnAgent::Codex => Self::codex(),
}
}
#[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}spawn:{reset} installing — {}",
self.human,
cyan = p.cyan,
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 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"
);
assert_eq!(
resolve_launch_token(None, None),
crate::harness::PLACEHOLDER_API_KEY
);
}
#[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 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"));
}
#[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_agent(SpawnAgent::Claude);
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_agent(SpawnAgent::Codex);
assert!(codex.display().contains("chatgpt.com/codex/install"));
#[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);
}
}