use super::*;
#[test]
fn the_gemini_client_is_pointed_at_the_isolated_home() {
let root = tempfile::tempdir().expect("isolated root");
let manager = ClientManager::isolated(root.path());
let mut command = Command::new("gemini");
configure_isolation(
&mut command,
&manager,
root.path(),
ClientKind::GeminiCli,
true,
)
.expect("configure gemini isolation");
let environment: std::collections::HashMap<_, _> = command
.get_envs()
.filter_map(|(key, value)| Some((key.to_string_lossy().into_owned(), value?)))
.collect();
for name in ["HOME", "GEMINI_CLI_HOME"] {
assert_eq!(
environment.get(name).map(|value| value.to_string_lossy()),
Some(root.path().to_string_lossy()),
"{name} must name the isolated root, not the .gemini directory \
inside it — the CLI appends `.gemini` itself"
);
}
assert_eq!(
manager.config_path(ClientKind::GeminiCli),
root.path().join(".gemini/settings.json")
);
assert_eq!(
environment
.get("GEMINI_CLI_TRUST_WORKSPACE")
.map(|value| value.to_string_lossy()),
Some(std::borrow::Cow::Borrowed("true"))
);
}
#[test]
fn default_claude_launch_uses_an_empty_persistent_router_profile() {
let profiles = tempfile::tempdir().expect("profile root");
let models = [RouterModel {
id: "test-model".to_string(),
owned_by: "test".to_string(),
..RouterModel::default()
}];
let extended = TemporaryClient::prepare(&Preparation {
client: ClientKind::ClaudeCode,
base_url: "http://router.test",
token: "task-token",
model_override: None,
models: &models,
isolated_config: false,
extend_user_configuration: false,
one_shot: true,
profile_root: Some(profiles.path()),
codex_reasoning_effort: None,
codex_backend_base_url: None,
})
.expect("prepare with the default configuration handling");
let names: Vec<String> = extended
.command
.get_envs()
.map(|(name, _)| name.to_string_lossy().into_owned())
.collect();
let config_dir = extended
.command
.get_envs()
.find_map(|(name, value)| (name == "CLAUDE_CONFIG_DIR").then_some(value?))
.expect("default Claude launch must set CLAUDE_CONFIG_DIR");
let expected = profiles
.path()
.join("link-assistant-router/clients/claude/home");
assert_eq!(Path::new(config_dir), expected);
assert_eq!(
fs::read_dir(&expected)
.expect("read Router-owned Claude profile")
.count(),
0,
"Router must create only the empty profile and let Claude populate it"
);
for required in ["ANTHROPIC_AUTH_TOKEN", "ANTHROPIC_BASE_URL"] {
assert!(
names.iter().any(|name| name == required),
"{required} missing: {names:?}"
);
}
let environment = extended
.command
.get_envs()
.filter_map(|(name, value)| {
Some((
name.to_string_lossy().into_owned(),
value?.to_string_lossy().into_owned(),
))
})
.collect::<std::collections::HashMap<_, _>>();
assert_eq!(
environment.get("ANTHROPIC_BASE_URL").map(String::as_str),
Some("http://router.test/api/services/anthropic")
);
assert_eq!(
environment
.get("CLAUDE_CODE_ENABLE_GATEWAY_MODEL_DISCOVERY")
.map(String::as_str),
Some("1")
);
assert!(
!names
.iter()
.any(|name| name == "CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC"),
"the run must inherit the user's setting instead of installing the presence-based disable switch"
);
assert_eq!(
environment.get("ANTHROPIC_API_KEY").map(String::as_str),
Some("")
);
for untouched in [
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
] {
assert!(!environment.contains_key(untouched), "{untouched}");
}
let isolated = TemporaryClient::prepare(&Preparation {
client: ClientKind::ClaudeCode,
base_url: "http://router.test",
token: "task-token",
model_override: None,
models: &models,
isolated_config: true,
extend_user_configuration: false,
one_shot: true,
profile_root: None,
codex_reasoning_effort: None,
codex_backend_base_url: None,
})
.expect("prepare isolated");
assert!(
isolated
.command
.get_envs()
.any(|(name, _)| name == "CLAUDE_CONFIG_DIR"),
"--isolated-config must still give the client its own directory"
);
}
#[test]
fn zai_only_claude_launch_pins_only_main_and_subagent() {
let profiles = tempfile::tempdir().expect("profile root");
let models: Vec<RouterModel> = serde_json::from_value(json!([
{"id": "future-first-2099", "owned_by": "z.ai", "client_capabilities": {"claude": {"behaves_as": "claude-sonnet-4-5", "source": "provider-protocol:z.ai-anthropic"}}},
{"id": "future-explicit-2099", "owned_by": "z.ai", "client_capabilities": {"claude": {"behaves_as": "claude-sonnet-4-5", "source": "provider-protocol:z.ai-anthropic"}}}
]))
.expect("deserialize profiled z.ai models");
let resumed = TemporaryClient::prepare(&Preparation {
client: ClientKind::ClaudeCode,
base_url: "http://router.test",
token: "task-token",
model_override: None,
models: &models,
isolated_config: false,
extend_user_configuration: false,
one_shot: false,
profile_root: Some(profiles.path()),
codex_reasoning_effort: None,
codex_backend_base_url: None,
})
.expect("prepare a resumed z.ai-only Claude session");
let resumed_env = resumed
.command
.get_envs()
.filter_map(|(key, value)| {
Some((
key.to_string_lossy().into_owned(),
value?.to_string_lossy().into_owned(),
))
})
.collect::<std::collections::HashMap<_, _>>();
for key in crate::clients::CLAUDE_GATEWAY_TARGET_ENV {
assert_eq!(
resumed_env.get(key).map(String::as_str),
Some("future-first-2099"),
"{key}"
);
}
for key in crate::clients::CLAUDE_MODEL_ENV {
if !crate::clients::CLAUDE_GATEWAY_TARGET_ENV.contains(&key) {
assert!(!resumed_env.contains_key(key), "{key}");
}
}
let explicit = TemporaryClient::prepare(&Preparation {
client: ClientKind::ClaudeCode,
base_url: "http://router.test",
token: "task-token",
model_override: Some("future-explicit-2099"),
models: &models,
isolated_config: false,
extend_user_configuration: false,
one_shot: true,
profile_root: Some(profiles.path()),
codex_reasoning_effort: None,
codex_backend_base_url: None,
})
.expect("prepare an explicit z.ai Claude model");
let explicit_env = explicit
.command
.get_envs()
.filter_map(|(key, value)| {
Some((
key.to_string_lossy().into_owned(),
value?.to_string_lossy().into_owned(),
))
})
.collect::<std::collections::HashMap<_, _>>();
for key in crate::clients::CLAUDE_GATEWAY_TARGET_ENV {
assert_eq!(
explicit_env.get(key).map(String::as_str),
Some("future-explicit-2099"),
"explicit model must win for {key}"
);
}
for key in crate::clients::CLAUDE_MODEL_ENV {
if !crate::clients::CLAUDE_GATEWAY_TARGET_ENV.contains(&key) {
assert!(!explicit_env.contains_key(key), "{key}");
}
}
}
#[test]
fn claude_picker_adds_each_filtered_authorized_model_exactly_once() {
let profiles = tempfile::tempdir().expect("profile root");
let models: Vec<RouterModel> = serde_json::from_value(json!([
{"id": "future-native-id", "owned_by": "anthropic"},
{"id": "future-claude-shaped-zai", "owned_by": "z.ai", "client_capabilities": {"claude": {"behaves_as": "claude-sonnet-4-5", "source": "provider-protocol:z.ai-anthropic"}}},
{"id": "future-glm-beta", "owned_by": "z.ai", "client_capabilities": {"claude": {"behaves_as": "claude-sonnet-4-5", "source": "provider-protocol:z.ai-anthropic"}}},
{"id": "future-glm-alpha", "owned_by": "z.ai", "client_capabilities": {"claude": {"behaves_as": "claude-sonnet-4-5", "source": "provider-protocol:z.ai-anthropic"}}},
{"id": "future-glm-alpha", "owned_by": "z.ai", "client_capabilities": {"claude": {"behaves_as": "claude-sonnet-4-5", "source": "provider-protocol:z.ai-anthropic"}}},
{"id": "sonnet", "owned_by": "z.ai", "client_capabilities": {"claude": {"behaves_as": "claude-sonnet-4-5", "source": "provider-protocol:z.ai-anthropic"}}}
]))
.expect("deserialize client capability fixture");
let prepared = TemporaryClient::prepare(&Preparation {
client: ClientKind::ClaudeCode,
base_url: "http://router.test",
token: "task-token",
model_override: None,
models: &models,
isolated_config: false,
extend_user_configuration: false,
one_shot: false,
profile_root: Some(profiles.path()),
codex_reasoning_effort: None,
codex_backend_base_url: None,
})
.expect("prepare mixed Claude catalog");
let arguments = prepared
.command
.get_args()
.map(|argument| argument.to_string_lossy().into_owned())
.collect::<Vec<_>>();
let settings = arguments
.windows(2)
.find_map(|pair| (pair[0] == "--settings").then_some(&pair[1]))
.expect("Router must provide a process-local model picker");
let settings: serde_json::Value = serde_json::from_str(settings).expect("valid settings JSON");
assert_eq!(
settings,
json!({
"modelPicker": {
"options": [
{"model": "future-claude-shaped-zai", "label": "future-claude-shaped-zai", "behavesAs": "claude-sonnet-4-5"},
{"model": "future-glm-alpha", "label": "future-glm-alpha", "behavesAs": "claude-sonnet-4-5"},
{"model": "future-glm-beta", "label": "future-glm-beta", "behavesAs": "claude-sonnet-4-5"}
],
"replaceBuiltInOptions": false
}
})
);
}
#[test]
fn claude_picker_fails_closed_when_a_dynamic_model_has_no_profile() {
let profiles = tempfile::tempdir().expect("profile root");
let models = [RouterModel {
id: "glm-looking-but-unverified".to_string(),
owned_by: crate::clients::ZAI_MODEL_OWNER.to_string(),
..RouterModel::default()
}];
let result = TemporaryClient::prepare(&Preparation {
client: ClientKind::ClaudeCode,
base_url: "http://router.test",
token: "task-token",
model_override: None,
models: &models,
isolated_config: false,
extend_user_configuration: false,
one_shot: false,
profile_root: Some(profiles.path()),
codex_reasoning_effort: None,
codex_backend_base_url: None,
});
let Err(error) = result else {
panic!("an unknown capability profile must not reach Claude Code");
};
assert!(
error.to_string().contains("glm-looking-but-unverified"),
"{error}"
);
assert!(error.to_string().contains("capability metadata"), "{error}");
let models: Vec<RouterModel> = serde_json::from_value(json!([
{"id": "future-conflict", "owned_by": "z.ai", "client_capabilities": {"claude": {"behaves_as": "claude-sonnet-4-5", "source": "provider-protocol:z.ai-anthropic"}}},
{"id": "future-conflict", "owned_by": "z.ai", "client_capabilities": {"claude": {"behaves_as": "claude-haiku-4-5", "source": "provider-protocol:z.ai-anthropic"}}}
]))
.expect("deserialize conflicting capability fixture");
let result = TemporaryClient::prepare(&Preparation {
client: ClientKind::ClaudeCode,
base_url: "http://router.test",
token: "task-token",
model_override: None,
models: &models,
isolated_config: false,
extend_user_configuration: false,
one_shot: false,
profile_root: Some(profiles.path()),
codex_reasoning_effort: None,
codex_backend_base_url: None,
});
let Err(error) = result else {
panic!("conflicting capability profiles must not reach Claude Code");
};
assert!(error.to_string().contains("future-conflict"), "{error}");
assert!(error.to_string().contains("ambiguous"), "{error}");
}
#[test]
fn codex_overlays_routing_without_repointing_user_configuration() {
let models = [RouterModel {
id: "gpt-5.6-sol".to_string(),
owned_by: "codex".to_string(),
default_reasoning_level: Some("high".to_string()),
supported_reasoning_levels: Some(vec![crate::clients::RouterReasoningLevel {
effort: "high".to_string(),
description: "Deep reasoning".to_string(),
}]),
client_capabilities: crate::clients::RouterClientCapabilities::default(),
}];
assert!(
extends_user_configuration(ClientKind::Codex, false, false),
"ordinary Codex runs can layer routing through CLI configuration"
);
assert!(
!extends_user_configuration(ClientKind::Codex, true, false),
"explicit isolation must still replace the client configuration"
);
let prepared = TemporaryClient::prepare(&Preparation {
client: ClientKind::Codex,
base_url: "http://router.test/path?tenant=one",
token: "la_sk_header.payload.sig",
model_override: None,
models: &models,
isolated_config: false,
extend_user_configuration: false,
one_shot: true,
profile_root: None,
codex_reasoning_effort: None,
codex_backend_base_url: Some("http://127.0.0.1:43123/api/services/codex/backend-api"),
})
.expect("prepare Codex overlay");
let environment = prepared
.command
.get_envs()
.map(|(name, value)| (name.to_string_lossy().into_owned(), value))
.collect::<std::collections::HashMap<_, _>>();
assert!(!environment.contains_key("HOME"), "{environment:?}");
assert!(!environment.contains_key("CODEX_HOME"), "{environment:?}");
assert_eq!(
environment
.get("LINK_ASSISTANT_TOKEN")
.and_then(|value| *value)
.map(|value| value.to_string_lossy()),
Some(std::borrow::Cow::Borrowed("la_sk_header.payload.sig"))
);
assert_eq!(
environment
.get("CODEX_ACCESS_TOKEN")
.and_then(|value| *value)
.map(|value| value.to_string_lossy()),
Some(std::borrow::Cow::Borrowed("at-header.payload.sig"))
);
assert_eq!(
environment
.get("CODEX_CONNECTORS_TOKEN")
.and_then(|value| *value)
.map(|value| value.to_string_lossy()),
Some(std::borrow::Cow::Borrowed("at-header.payload.sig"))
);
assert_eq!(
environment
.get("CODEX_AUTHAPI_BASE_URL")
.and_then(|value| *value)
.map(|value| value.to_string_lossy()),
Some(std::borrow::Cow::Borrowed(
"http://router.test/path?tenant=one/api/services/codex"
))
);
let arguments = prepared
.command
.get_args()
.map(|argument| argument.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(arguments[0], "-c");
let provider = arguments[1]
.strip_prefix("model_provider=")
.and_then(|value| serde_json::from_str::<String>(value).ok())
.expect("process-local model provider argument");
assert!(provider.starts_with("link-assistant-run-"), "{provider}");
assert_eq!(arguments[2], "-c");
let catalog_path = arguments[3]
.strip_prefix("model_catalog_json=")
.and_then(|value| serde_json::from_str::<String>(value).ok())
.expect("process-local model catalog argument");
assert!(Path::new(&catalog_path).starts_with(prepared.directory.path()));
let catalog: serde_json::Value = serde_json::from_slice(
&std::fs::read(&catalog_path).expect("read process-local model catalog"),
)
.expect("parse process-local model catalog");
assert_eq!(catalog["models"][0]["slug"], "gpt-5.6-sol");
assert_eq!(catalog["models"][0]["default_reasoning_level"], "high");
assert_eq!(
catalog["models"][0]["supported_reasoning_levels"],
json!([{"effort": "high", "description": "Deep reasoning"}])
);
assert_eq!(catalog["models"].as_array().unwrap().len(), 1);
assert_eq!(arguments[4], "-c");
assert_eq!(
arguments[5],
format!(
"model_providers.{provider}={{ name = \"OpenAI\", base_url = \"http://router.test/path?tenant=one/api/services/codex/v1\", wire_api = \"responses\", requires_openai_auth = true, supports_websockets = true, supports_standalone_web_search = true }}"
)
);
assert!(!arguments[5].contains("env_key"));
assert_eq!(
arguments[6..],
[
"-c",
"chatgpt_base_url=\"http://127.0.0.1:43123/api/services/codex/backend-api\"",
"-c",
"experimental_realtime_ws_base_url=\"http://router.test/path?tenant=one/api/services/codex/v1\"",
"-c",
"experimental_realtime_webrtc_call_base_url=\"http://router.test/path?tenant=one/api/services/codex/v1\"",
]
);
let isolated = TemporaryClient::prepare(&Preparation {
client: ClientKind::Codex,
base_url: "http://router.test",
token: "task-token",
model_override: None,
models: &models,
isolated_config: true,
extend_user_configuration: false,
one_shot: true,
profile_root: None,
codex_reasoning_effort: None,
codex_backend_base_url: None,
})
.expect("prepare isolated Codex");
let isolated_home = isolated
.command
.get_envs()
.find_map(|(name, value)| (name == "HOME").then_some(value?))
.expect("isolated Codex sets HOME");
assert_eq!(Path::new(isolated_home), isolated.directory.path());
assert!(
isolated
.command
.get_envs()
.any(|(name, value)| name == "CODEX_HOME" && value.is_none()),
"isolation must prevent an inherited CODEX_HOME from escaping"
);
assert!(isolated.command.get_args().next().is_none());
assert!(
isolated
.directory
.path()
.join(".codex/config.toml")
.is_file()
);
}
#[test]
fn codex_catalog_preserves_per_model_live_reasoning_metadata() {
let root = tempfile::tempdir().expect("temporary catalog directory");
let models = [
RouterModel {
id: "future-reasoning-a".to_string(),
owned_by: "openai".to_string(),
default_reasoning_level: Some("medium".to_string()),
supported_reasoning_levels: Some(vec![
crate::clients::RouterReasoningLevel {
effort: "low".to_string(),
description: "Faster answers".to_string(),
},
crate::clients::RouterReasoningLevel {
effort: "medium".to_string(),
description: "Balanced reasoning".to_string(),
},
crate::clients::RouterReasoningLevel {
effort: "xhigh".to_string(),
description: "Deepest reasoning".to_string(),
},
]),
client_capabilities: crate::clients::RouterClientCapabilities::default(),
},
RouterModel {
id: "future-reasoning-b".to_string(),
owned_by: "openai".to_string(),
default_reasoning_level: Some("xhigh".to_string()),
supported_reasoning_levels: Some(vec![crate::clients::RouterReasoningLevel {
effort: "xhigh".to_string(),
description: "Only supported level".to_string(),
}]),
client_capabilities: crate::clients::RouterClientCapabilities::default(),
},
];
let path =
write_codex_model_catalog(root.path(), &models, None, None).expect("write live catalog");
let catalog: serde_json::Value =
serde_json::from_slice(&std::fs::read(path).expect("read generated catalog"))
.expect("parse generated catalog");
assert_eq!(
catalog["models"][0]["supported_reasoning_levels"],
json!([
{"effort": "low", "description": "Faster answers"},
{"effort": "medium", "description": "Balanced reasoning"},
{"effort": "xhigh", "description": "Deepest reasoning"}
])
);
assert_eq!(
catalog["models"][1]["supported_reasoning_levels"],
json!([{"effort": "xhigh", "description": "Only supported level"}])
);
assert_eq!(catalog["models"][0]["default_reasoning_level"], "medium");
assert_eq!(catalog["models"][1]["default_reasoning_level"], "xhigh");
}
#[test]
fn codex_catalog_omits_unknown_reasoning_metadata_without_blocking_healthy_models() {
let root = tempfile::tempdir().expect("temporary catalog directory");
let models = [
RouterModel {
id: "future-reasoning-unknown".to_string(),
owned_by: "unknown-provider".to_string(),
default_reasoning_level: None,
supported_reasoning_levels: None,
client_capabilities: crate::clients::RouterClientCapabilities::default(),
},
RouterModel {
id: "future-reasoning-known".to_string(),
owned_by: "openai".to_string(),
default_reasoning_level: Some("high".to_string()),
supported_reasoning_levels: Some(vec![crate::clients::RouterReasoningLevel {
effort: "high".to_string(),
description: "Deep reasoning".to_string(),
}]),
client_capabilities: crate::clients::RouterClientCapabilities::default(),
},
];
let path = write_codex_model_catalog(root.path(), &models, None, None)
.expect("the fully described model must remain launchable");
let catalog: serde_json::Value =
serde_json::from_slice(&std::fs::read(path).expect("read generated catalog"))
.expect("parse generated catalog");
let slugs = catalog["models"]
.as_array()
.unwrap()
.iter()
.map(|model| model["slug"].as_str().unwrap())
.collect::<Vec<_>>();
assert_eq!(slugs, ["future-reasoning-known"]);
let error =
write_codex_model_catalog(root.path(), &models, None, Some("future-reasoning-unknown"))
.expect_err("an explicitly selected incomplete model must remain a hard error")
.to_string();
assert!(error.contains("future-reasoning-unknown"), "{error}");
assert!(error.contains("reasoning metadata"), "{error}");
}
#[test]
fn codex_catalog_never_offers_a_model_that_would_reset_an_explicit_effort() {
let root = tempfile::tempdir().expect("temporary catalog directory");
let models = [
RouterModel {
id: "future-supports-xhigh".to_string(),
owned_by: "openai".to_string(),
default_reasoning_level: Some("medium".to_string()),
supported_reasoning_levels: Some(vec![
crate::clients::RouterReasoningLevel {
effort: "medium".to_string(),
description: "Balanced reasoning".to_string(),
},
crate::clients::RouterReasoningLevel {
effort: "xhigh".to_string(),
description: "Deepest reasoning".to_string(),
},
]),
client_capabilities: crate::clients::RouterClientCapabilities::default(),
},
RouterModel {
id: "future-medium-only".to_string(),
owned_by: "openai".to_string(),
default_reasoning_level: Some("medium".to_string()),
supported_reasoning_levels: Some(vec![crate::clients::RouterReasoningLevel {
effort: "medium".to_string(),
description: "Only supported level".to_string(),
}]),
client_capabilities: crate::clients::RouterClientCapabilities::default(),
},
];
let path = write_codex_model_catalog(root.path(), &models, Some("xhigh"), None)
.expect("write compatibility catalog");
let catalog: serde_json::Value =
serde_json::from_slice(&std::fs::read(path).expect("read generated catalog"))
.expect("parse generated catalog");
let slugs = catalog["models"]
.as_array()
.expect("models array")
.iter()
.filter_map(|model| model["slug"].as_str())
.collect::<Vec<_>>();
assert_eq!(slugs, ["future-supports-xhigh"]);
let error = write_codex_model_catalog(
root.path(),
&models,
Some("xhigh"),
Some("future-medium-only"),
)
.expect_err("an explicit unsupported model must be rejected")
.to_string();
assert!(error.contains("future-medium-only"), "{error}");
assert!(error.contains("xhigh"), "{error}");
}
#[test]
fn a_file_configured_client_is_isolated_even_by_default() {
let models = [RouterModel {
id: "test-model".to_string(),
owned_by: "test".to_string(),
..RouterModel::default()
}];
assert!(
!extends_user_configuration(ClientKind::Opencode, false, false),
"opencode sets no base-url variable, so there is nothing to layer"
);
assert!(
!extends_user_configuration(ClientKind::ClaudeCode, false, false),
"Claude defaults to its persistent Router-owned profile"
);
assert!(
extends_user_configuration(ClientKind::ClaudeCode, false, true),
"--extend-global-config explicitly opts into the real Claude profile"
);
assert!(
!extends_user_configuration(ClientKind::ClaudeCode, true, true),
"--isolated-config wins over the default"
);
let profiles = tempfile::tempdir().expect("profile root");
TemporaryClient::prepare(&Preparation {
client: ClientKind::Opencode,
base_url: "http://router.test",
token: "task-token",
model_override: None,
models: &models,
isolated_config: false,
extend_user_configuration: false,
one_shot: true,
profile_root: Some(profiles.path()),
codex_reasoning_effort: None,
codex_backend_base_url: None,
})
.expect("a file-configured client must still run");
}
#[test]
fn a_client_needing_a_written_file_is_isolated_despite_its_variables() {
let integration = ClientKind::GeminiCli.integration();
assert!(
integration.token_env.is_some() && integration.base_url_env.is_some(),
"the variables alone would otherwise qualify it for extending"
);
assert!(
!extends_user_configuration(ClientKind::GeminiCli, false, false),
"routing depends on a file only isolation makes reachable"
);
}
#[test]
fn a_prepared_gemini_run_leaves_settings_where_the_cli_reads_them() {
let models = [RouterModel {
id: "test-model".to_string(),
owned_by: "test".to_string(),
..RouterModel::default()
}];
let profiles = tempfile::tempdir().expect("profile root");
let temporary = TemporaryClient::prepare(&Preparation {
client: ClientKind::GeminiCli,
base_url: "http://router.test",
token: "task-token",
model_override: None,
models: &models,
isolated_config: false,
extend_user_configuration: false,
one_shot: true,
profile_root: Some(profiles.path()),
codex_reasoning_effort: None,
codex_backend_base_url: None,
})
.expect("prepare gemini");
let root = temporary.directory.path();
let home = temporary
.command
.get_envs()
.find_map(|(name, value)| (name == "HOME").then_some(value?))
.expect("gemini run sets HOME");
let settings = Path::new(home).join(".gemini/settings.json");
assert!(
settings.is_file(),
"no settings at {}, which is where the CLI looks",
settings.display()
);
let written = fs::read_to_string(&settings).expect("read settings");
assert!(written.contains("gemini-api-key"), "{written}");
assert!(Path::new(home).starts_with(root), "HOME escaped the root");
}
#[test]
fn written_gemini_settings_replace_an_existing_file() {
let root = tempfile::tempdir().expect("isolated root");
let path = root.path().join(".gemini/settings.json");
fs::create_dir_all(path.parent().expect("parent")).expect("create directory");
fs::write(
&path,
r#"{"security":{"auth":{"selectedType":"oauth-personal"}}}"#,
)
.expect("seed a conflicting file");
write_gemini_settings(&path).expect("write settings");
let written = fs::read_to_string(&path).expect("read settings");
assert!(written.contains("gemini-api-key"), "{written}");
assert!(
!written.contains("oauth-personal"),
"the inherited value survived: {written}"
);
}
#[test]
fn gemini_settings_select_the_api_key_flow() {
let root = tempfile::tempdir().expect("isolated root");
let path = root.path().join(".gemini/settings.json");
write_gemini_settings(&path).expect("write settings");
let written: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&path).expect("read")).expect("valid JSON");
assert_eq!(
written["security"]["auth"]["selectedType"],
"gemini-api-key"
);
}
#[test]
fn a_client_that_cannot_be_extended_keeps_its_profile() {
let models = [RouterModel {
id: "test-model".to_string(),
owned_by: "test".to_string(),
default_reasoning_level: Some("medium".to_string()),
supported_reasoning_levels: Some(vec![crate::clients::RouterReasoningLevel {
effort: "medium".to_string(),
description: "Test reasoning".to_string(),
}]),
client_capabilities: crate::clients::RouterClientCapabilities::default(),
}];
let profiles = tempfile::tempdir().expect("profile root");
for client in ClientKind::ALL {
if client == ClientKind::Cursor {
assert!(
TemporaryClient::prepare(&Preparation {
client,
base_url: "http://router.test",
token: "task-token",
model_override: None,
models: &models,
isolated_config: false,
extend_user_configuration: false,
one_shot: true,
profile_root: Some(profiles.path()),
codex_reasoning_effort: None,
codex_backend_base_url: None,
})
.is_err()
);
continue;
}
let temporary = TemporaryClient::prepare(&Preparation {
client,
base_url: "http://router.test",
token: "task-token",
model_override: None,
models: &models,
isolated_config: false,
extend_user_configuration: false,
one_shot: true,
profile_root: Some(profiles.path()),
codex_reasoning_effort: None,
codex_backend_base_url: None,
})
.unwrap_or_else(|error| panic!("{client} failed setup: {error}"));
let root = temporary.directory.path().to_path_buf();
assert_eq!(temporary.command.get_program(), client.command());
let environment = temporary
.command
.get_envs()
.filter_map(|(name, value)| value.map(|value| (name, value)))
.collect::<std::collections::HashMap<_, _>>();
if let Some(token_env) = client.token_env() {
assert_eq!(
environment.get(std::ffi::OsStr::new(token_env)).copied(),
Some(std::ffi::OsStr::new("task-token")),
"{client} did not receive its token environment"
);
}
for name in [
"HOME",
"CLAUDE_CONFIG_DIR",
"GEMINI_CLI_HOME",
"OPENCODE_CONFIG",
"OPENCODE_CONFIG_DIR",
] {
if let Some(value) = environment.get(std::ffi::OsStr::new(name)) {
assert!(
Path::new(value).starts_with(&root),
"{client} {name} escaped its root"
);
}
}
let keeps_a_profile = !extends_user_configuration(client, false, false);
drop(temporary);
assert_eq!(
root.exists(),
keeps_a_profile,
"{client}: a client routed through a written file must keep its profile, and \
one that only needs two environment variables must not leave a directory behind"
);
if keeps_a_profile {
assert!(
root.starts_with(profiles.path()),
"{client} profile must live under the router's own directory, not TMPDIR: \
{}",
root.display()
);
}
}
}
#[test]
fn two_runs_of_the_same_client_share_one_profile() {
let profiles = tempfile::tempdir().expect("profile root");
let root = Some(profiles.path());
let first = persistent_profile(ClientKind::Codex, root).expect("first profile");
let second = persistent_profile(ClientKind::Codex, root).expect("second profile");
assert_eq!(first, second);
assert!(first.is_dir());
assert_ne!(
first,
persistent_profile(ClientKind::GeminiCli, root).expect("another client")
);
}
#[test]
fn registry_order_matches_client_discriminants() {
for client in ClientKind::ALL {
assert_eq!(client.integration().kind, client);
}
}
#[test]
fn the_default_label_carries_no_directory_name() {
let label = format!("with-{}-{}", ClientKind::ClaudeCode, super::run_suffix());
assert!(label.starts_with("with-claude-"), "{label}");
let cwd = std::env::current_dir().expect("cwd");
let name = cwd
.file_name()
.expect("directory name")
.to_string_lossy()
.into_owned();
assert!(
!label.contains(&name),
"the working directory's name must not reach the router: {label} contains {name}"
);
assert_eq!(super::run_suffix().len(), 4, "a fixed-width run suffix");
assert_eq!(
super::run_suffix(),
super::run_suffix(),
"stable within one process, so one run has one label"
);
}