use crate::config::{
self, AuthProviderRecord, CredentialReadiness, McPaths, OPENAI_CODEX_CLIENT_ID,
OPENAI_CODEX_REDIRECT_URI as REDIRECT_URI,
};
use crate::providers::{ANTHROPIC_PROVIDER, OPENAI_CODEX_PROVIDER};
use anyhow::Context;
use base64::Engine;
use chrono::Utc;
use sha2::{Digest, Sha256};
use std::fmt;
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc::Receiver,
};
use std::time::{Duration, Instant};
const AUTHORIZE_URL: &str = "https://auth.openai.com/oauth/authorize";
const CALLBACK_ADDR: &str = "127.0.0.1:1455";
const SCOPE: &str = "openid profile email offline_access";
const LOGIN_WAIT_TIMEOUT: Duration = Duration::from_secs(300);
#[cfg(not(test))]
const CALLBACK_STREAM_TIMEOUT: Duration = Duration::from_secs(5);
#[cfg(test)]
const CALLBACK_STREAM_TIMEOUT: Duration = Duration::from_millis(100);
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct LoginProvider {
pub(crate) id: &'static str,
pub(crate) label: &'static str,
pub(crate) description: &'static str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LoginProviderStatus {
Missing,
Configured,
NeedsRelogin,
}
impl LoginProviderStatus {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Missing => "missing",
Self::Configured => "configured/refreshable",
Self::NeedsRelogin => "needs re-login",
}
}
}
pub(crate) const CUSTOM_PROVIDER_LOGIN_ID: &str = "custom-provider";
const CUSTOM_PROVIDER_SETUP_STATUS: &str = "configure with prompts";
const ANTHROPIC_READY_STATUS: &str = "ready via Anthropic API key";
const ANTHROPIC_NEEDS_API_KEY_STATUS: &str = "needs API key";
pub(crate) fn anthropic_api_key_setup_guidance() -> &'static str {
"export ANTHROPIC_API_KEY before launching magi-code, then restart magi-code; or configure provider-keyed anthropic API-key auth"
}
pub(crate) fn unsupported_login_provider_message(provider_id: &str) -> String {
format!(
"unsupported login provider '{provider_id}'; supported providers: openai-codex, anthropic, custom-provider; for anthropic {}",
anthropic_api_key_setup_guidance()
)
}
pub(crate) fn providers() -> Vec<LoginProvider> {
vec![
LoginProvider {
id: OPENAI_CODEX_PROVIDER,
label: "OpenAI Codex",
description: "ChatGPT account OAuth for Codex-backed assistant models",
},
LoginProvider {
id: ANTHROPIC_PROVIDER,
label: "Anthropic",
description: "Anthropic Messages API using ANTHROPIC_API_KEY or provider-keyed API-key auth",
},
LoginProvider {
id: CUSTOM_PROVIDER_LOGIN_ID,
label: "Custom Provider",
description: "OpenAI-compatible API root plus optional API-key environment variable name",
},
]
}
pub(crate) fn provider_status(
paths: &McPaths,
provider_id: &str,
) -> anyhow::Result<LoginProviderStatus> {
if provider_id == ANTHROPIC_PROVIDER
&& std::env::var("ANTHROPIC_API_KEY")
.ok()
.is_some_and(|value| !value.is_empty())
{
return Ok(LoginProviderStatus::Configured);
}
let auth = config::read_auth(paths)
.map_err(|error| anyhow::anyhow!("could not read auth store: {error}"))?;
Ok(
match config::classify_provider_auth_record(
provider_id,
auth.providers.get(provider_id),
Utc::now().timestamp(),
) {
CredentialReadiness::Missing => LoginProviderStatus::Missing,
CredentialReadiness::Ready | CredentialReadiness::Refreshable => {
LoginProviderStatus::Configured
}
CredentialReadiness::Invalid => LoginProviderStatus::NeedsRelogin,
},
)
}
pub(crate) fn login_provider_status_label(
paths: &McPaths,
provider_id: &str,
) -> anyhow::Result<&'static str> {
if provider_id == CUSTOM_PROVIDER_LOGIN_ID {
return Ok(CUSTOM_PROVIDER_SETUP_STATUS);
}
let status = provider_status(paths, provider_id)?;
if provider_id == ANTHROPIC_PROVIDER {
return Ok(match status {
LoginProviderStatus::Configured => ANTHROPIC_READY_STATUS,
LoginProviderStatus::Missing | LoginProviderStatus::NeedsRelogin => {
ANTHROPIC_NEEDS_API_KEY_STATUS
}
});
}
Ok(status.label())
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum LogoutProviderStatus {
Missing,
Configured,
NeedsRelogin,
NeedsApiKey,
}
impl LogoutProviderStatus {
pub(crate) fn label(self) -> &'static str {
match self {
Self::Missing => "not configured",
Self::Configured => "configured",
Self::NeedsRelogin => "needs re-login",
Self::NeedsApiKey => "needs API key",
}
}
}
pub(crate) fn logout_provider_status(
paths: &McPaths,
provider_id: &str,
) -> anyhow::Result<LogoutProviderStatus> {
let auth = config::read_auth(paths)?;
if provider_id == ANTHROPIC_PROVIDER {
return Ok(match auth.providers.get(provider_id) {
Some(AuthProviderRecord::ApiKey { key }) if !key.is_empty() => {
LogoutProviderStatus::Configured
}
Some(_) => LogoutProviderStatus::NeedsApiKey,
None => LogoutProviderStatus::Missing,
});
}
Ok(match auth.providers.get(provider_id) {
Some(AuthProviderRecord::OAuth {
access,
refresh,
account_id,
..
}) if (!access.is_empty() || refresh.as_ref().is_some_and(|value| !value.is_empty()))
&& account_id.as_ref().is_some_and(|id| !id.is_empty()) =>
{
LogoutProviderStatus::Configured
}
Some(_) => LogoutProviderStatus::NeedsRelogin,
None => LogoutProviderStatus::Missing,
})
}
pub(crate) fn logout_provider_list_text(paths: &McPaths) -> anyhow::Result<String> {
let mut out = String::from("logout providers:\n");
for provider in providers() {
let status = logout_provider_status(paths, provider.id)?.label();
out.push_str(&format!(
"- {} ({}) [{status}]: {}\n",
provider.label, provider.id, provider.description
));
}
if let Ok(settings) = config::read_settings(paths) {
for (id, provider) in settings.custom_providers {
out.push_str(&format!(
"- {} ({}) [configured]: custom provider metadata\n",
provider.label, id
));
}
}
out.push_str("usage: /logout openai-codex, /logout anthropic, or /logout <custom-provider-id>");
Ok(out)
}
pub(crate) fn validate_logout_provider(provider_id: &str) -> anyhow::Result<LoginProvider> {
providers()
.into_iter()
.find(|provider| provider.id == provider_id)
.ok_or_else(|| {
anyhow::anyhow!(
"unsupported logout provider '{provider_id}'; supported providers: openai-codex, anthropic, and configured custom provider ids; usage: /logout anthropic"
)
})
}
#[derive(Clone)]
pub(crate) struct OAuthAttempt {
verifier: String,
state: String,
challenge: String,
}
impl fmt::Debug for OAuthAttempt {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OAuthAttempt")
.field("verifier", &"<redacted>")
.field("state", &"<redacted>")
.field("challenge", &"<redacted>")
.finish()
}
}
impl OAuthAttempt {
pub(crate) fn new() -> Self {
let verifier = random_urlsafe(64);
let state = random_urlsafe(32);
let digest = Sha256::digest(verifier.as_bytes());
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
Self {
verifier,
state,
challenge,
}
}
pub(crate) fn authorization_url(&self) -> String {
let pairs = [
("response_type", "code"),
("client_id", OPENAI_CODEX_CLIENT_ID),
("redirect_uri", REDIRECT_URI),
("scope", SCOPE),
("code_challenge", self.challenge.as_str()),
("code_challenge_method", "S256"),
("state", self.state.as_str()),
("id_token_add_organizations", "true"),
("codex_cli_simplified_flow", "true"),
("originator", "pi"),
];
let query = pairs
.into_iter()
.map(|(k, v)| format!("{}={}", pct(k), pct(v)))
.collect::<Vec<_>>()
.join("&");
format!("{AUTHORIZE_URL}?{query}")
}
}
pub(crate) struct LoginResult {
pub(crate) message: String,
}
#[derive(Clone, PartialEq, Eq)]
pub(crate) struct LoginInstructions {
pub(crate) url: String,
pub(crate) message: String,
}
impl fmt::Debug for LoginInstructions {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("LoginInstructions")
.field("url", &"<redacted>")
.field("message", &"<redacted>")
.finish()
}
}
pub(crate) fn configure_custom_provider(
paths: &McPaths,
id: &str,
label: &str,
base_url: &str,
api_key_env_var: &str,
) -> anyhow::Result<LoginResult> {
let id = config::validate_custom_provider_id(id)?;
let config = config::make_custom_provider_config(label, base_url, api_key_env_var)?;
let label = config.label.clone();
let auth_message = config
.api_key_env_var
.as_ref()
.map(|env_name| {
format!("API key env var: {env_name}. Set/export it with your API key before use.")
})
.unwrap_or_else(|| {
"API key not required; no Authorization header will be sent.".to_string()
});
config::upsert_custom_provider(paths, &id, config)?;
Ok(LoginResult {
message: format!(
"Custom provider '{label}' ({id}) configured. {auth_message} Next: run /setmodel {id}/<model-name>."
),
})
}
pub(crate) fn login_openai_codex_with_controls(
paths: &McPaths,
cancel: Arc<AtomicBool>,
manual_rx: Option<Receiver<String>>,
mut progress: impl FnMut(LoginInstructions) + Send + 'static,
) -> anyhow::Result<LoginResult> {
let attempt = OAuthAttempt::new();
let url = attempt.authorization_url();
progress(LoginInstructions {
url: url.clone(),
message: format!(
"OpenAI Codex login: open this URL in your browser:\n{url}\nWaiting up to 5 minutes for browser callback on {REDIRECT_URI}. If callback cannot reach this terminal, paste the final redirect URL or authorization code into the active login prompt."
),
});
let code = capture_loopback_or_manual_code(
&attempt.state,
LOGIN_WAIT_TIMEOUT,
&cancel,
manual_rx.as_ref(),
)?;
if cancel.load(Ordering::Relaxed) {
anyhow::bail!("OpenAI Codex login cancelled; credentials unchanged")
}
let token = config::exchange_codex_code(&attempt.verifier, &code)?;
if cancel.load(Ordering::Relaxed) {
anyhow::bail!("OpenAI Codex login cancelled; credentials unchanged")
}
config::persist_codex_token(paths, token)?;
Ok(LoginResult {
message: "OpenAI Codex configured".to_string(),
})
}
pub(crate) fn service_codex_login(
paths: &McPaths,
generation: u64,
cancel: &AtomicBool,
manual_rx: &Receiver<String>,
progress: impl FnMut(&'static str, Option<String>),
) -> &'static str {
service_codex_login_with_exchange(
paths,
generation,
cancel,
manual_rx,
progress,
LOGIN_WAIT_TIMEOUT,
config::exchange_codex_code,
)
}
fn service_codex_login_with_exchange(
paths: &McPaths,
generation: u64,
cancel: &AtomicBool,
manual_rx: &Receiver<String>,
mut progress: impl FnMut(&'static str, Option<String>),
timeout: Duration,
exchange: impl FnOnce(&str, &str) -> anyhow::Result<config::NormalizedToken>,
) -> &'static str {
let attempt = OAuthAttempt::new();
progress("authorization_url", Some(attempt.authorization_url()));
progress("waiting", None);
let code = match capture_code_with_progress(
&attempt.state,
timeout,
cancel,
Some(manual_rx),
&mut || progress("callback_received", None),
) {
Ok(code) => code,
Err(_) if cancel.load(Ordering::Relaxed) => return "cancelled",
Err(error) if error.to_string().contains("timed out") => return "callback_timeout",
Err(_) => return "callback_failed",
};
progress("callback_accepted", None);
complete_service_codex_login(
paths,
generation,
cancel,
|| exchange(&attempt.verifier, &code),
|| {},
)
}
pub(crate) fn complete_service_codex_login(
paths: &McPaths,
generation: u64,
cancel: &AtomicBool,
exchange: impl FnOnce() -> anyhow::Result<config::NormalizedToken>,
after_commit: impl FnOnce(),
) -> &'static str {
if cancel.load(Ordering::Relaxed) {
return "cancelled";
}
let token = match exchange() {
Ok(token) => token,
Err(_) if cancel.load(Ordering::Relaxed) => return "cancelled",
Err(_) => return "exchange_failed",
};
if cancel.load(Ordering::Relaxed) {
return "cancelled";
}
match config::persist_codex_login_if_current(paths, generation, token, cancel) {
Ok(true) => {
after_commit();
"exchange_succeeded"
}
Ok(false) if cancel.load(Ordering::Relaxed) => "cancelled",
Ok(false) => "auth_changed",
Err(_) => "storage_failed",
}
}
fn capture_loopback_or_manual_code(
expected_state: &str,
timeout: Duration,
cancel: &AtomicBool,
manual_rx: Option<&Receiver<String>>,
) -> anyhow::Result<String> {
capture_code_with_progress(expected_state, timeout, cancel, manual_rx, &mut || {})
}
fn capture_code_with_progress(
expected_state: &str,
timeout: Duration,
cancel: &AtomicBool,
manual_rx: Option<&Receiver<String>>,
received: &mut impl FnMut(),
) -> anyhow::Result<String> {
let listener = match TcpListener::bind(CALLBACK_ADDR) {
Ok(listener) => {
listener.set_nonblocking(true)?;
Some(listener)
}
Err(_error) if manual_rx.is_some() => None,
Err(error) => return Err(error).with_context(|| "could not bind OAuth callback on 127.0.0.1:1455; use manual paste fallback if available, or retry from a terminal with local loopback access"),
};
let deadline = Instant::now() + timeout;
loop {
if cancel.load(Ordering::Relaxed) {
anyhow::bail!("OpenAI Codex login cancelled; credentials unchanged")
}
if let Some(rx) = manual_rx {
match rx.try_recv() {
Ok(input) => {
received();
return parse_manual_fallback_input(&input, expected_state);
}
Err(std::sync::mpsc::TryRecvError::Empty) => {}
Err(std::sync::mpsc::TryRecvError::Disconnected) if listener.is_none() => {
anyhow::bail!(
"manual OAuth fallback input closed and loopback callback is unavailable; retry /login openai-codex from a terminal with local loopback access"
)
}
Err(std::sync::mpsc::TryRecvError::Disconnected) => {}
}
}
if let Some(listener) = &listener {
match listener.accept() {
Ok((mut stream, _)) => {
received();
return handle_callback_stream(&mut stream, expected_state);
}
Err(error) if error.kind() == std::io::ErrorKind::WouldBlock => {}
Err(error) => return Err(error.into()),
}
}
if Instant::now() >= deadline {
anyhow::bail!(
"OAuth callback timed out; retry /login openai-codex or use manual paste fallback"
)
}
std::thread::sleep(Duration::from_millis(50));
}
}
fn handle_callback_stream(stream: &mut TcpStream, expected_state: &str) -> anyhow::Result<String> {
stream.set_read_timeout(Some(CALLBACK_STREAM_TIMEOUT))?;
stream.set_write_timeout(Some(CALLBACK_STREAM_TIMEOUT))?;
let mut buf = [0_u8; 4096];
let n = stream.read(&mut buf).map_err(|error| {
if matches!(
error.kind(),
std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
) {
anyhow::anyhow!(
"OAuth callback read timed out; retry /login openai-codex or use manual paste fallback"
)
} else {
error.into()
}
})?;
let request = String::from_utf8_lossy(&buf[..n]);
let first = request.lines().next().unwrap_or_default();
let result = parse_callback_request_line(first, expected_state);
let (status, body) = if result.is_ok() {
(
"200 OK",
"OpenAI Codex login complete. You can close this tab.",
)
} else {
(
"400 Bad Request",
"OpenAI Codex login failed. Return to your terminal.",
)
};
let response = format!(
"HTTP/1.1 {status}\r\ncontent-type: text/plain\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(response.as_bytes());
result
}
fn parse_callback_request_line(line: &str, expected_state: &str) -> anyhow::Result<String> {
let Some(target) = line
.strip_prefix("GET ")
.and_then(|rest| rest.split_whitespace().next())
else {
anyhow::bail!("OAuth callback was malformed")
};
parse_redirect_target(target, expected_state)
}
pub(crate) fn parse_manual_fallback_input(
input: &str,
expected_state: &str,
) -> anyhow::Result<String> {
let trimmed = input.trim();
if trimmed.is_empty() {
anyhow::bail!("manual OAuth fallback was empty")
}
if trimmed.contains("?") || trimmed.starts_with("http://") || trimmed.starts_with("https://") {
let target = trimmed
.split_once("://")
.and_then(|(_, rest)| rest.find('/').map(|idx| &rest[idx..]))
.unwrap_or(trimmed);
return parse_redirect_target(target, expected_state).map_err(|error| {
anyhow::anyhow!(
"manual OAuth fallback rejected: {}",
redact_oauth_text(&error.to_string())
)
});
}
if trimmed.contains(char::is_whitespace) || trimmed.contains('&') || trimmed.contains('=') {
anyhow::bail!(
"manual OAuth fallback was malformed; paste the full redirect URL or authorization code"
)
}
Ok(trimmed.to_string())
}
fn parse_redirect_target(target: &str, expected_state: &str) -> anyhow::Result<String> {
let (path, query) = target.split_once('?').unwrap_or((target, ""));
if !path.ends_with("/auth/callback") {
anyhow::bail!("OAuth callback used an unexpected path")
}
let params = parse_query(query)?;
if let Some(error) = params.iter().find(|(k, _)| k == "error").map(|(_, v)| v) {
anyhow::bail!(
"OAuth provider rejected login: {}",
redact_oauth_text(error)
)
}
let state = params
.iter()
.find(|(k, _)| k == "state")
.map(|(_, v)| v.as_str())
.unwrap_or_default();
if state != expected_state {
anyhow::bail!("OAuth callback state did not match; login was not completed")
}
params
.iter()
.find(|(k, _)| k == "code")
.map(|(_, v)| v.clone())
.filter(|v| !v.is_empty())
.ok_or_else(|| anyhow::anyhow!("OAuth callback did not include an authorization code"))
}
pub(crate) fn redact_oauth_text(text: &str) -> String {
crate::output::redact_sensitive_text(text)
.split("code=")
.next()
.map(|prefix| {
if prefix.len() == text.len() {
prefix.to_string()
} else {
format!("{prefix}code=<redacted>")
}
})
.unwrap_or_else(|| "<redacted>".to_string())
}
fn random_urlsafe(bytes: usize) -> String {
let mut out = Vec::new();
while out.len() < bytes {
out.extend_from_slice(uuid::Uuid::new_v4().as_bytes());
}
out.truncate(bytes);
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(out)
}
fn pct(input: &str) -> String {
let mut out = String::new();
for b in input.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(char::from(b))
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
fn parse_query(query: &str) -> anyhow::Result<Vec<(String, String)>> {
query
.split('&')
.filter(|part| !part.is_empty())
.map(|part| {
let (k, v) = part.split_once('=').unwrap_or((part, ""));
Ok((decode_pct(k)?, decode_pct(v)?))
})
.collect()
}
fn decode_pct(input: &str) -> anyhow::Result<String> {
let mut out = Vec::new();
let bytes = input.as_bytes();
let mut i = 0;
while i < bytes.len() {
if bytes[i] == b'%' {
if i + 2 >= bytes.len() {
anyhow::bail!("OAuth callback query contains malformed percent escape");
}
let high = hex_digit(bytes[i + 1]).ok_or_else(|| {
anyhow::anyhow!("OAuth callback query contains malformed percent escape")
})?;
let low = hex_digit(bytes[i + 2]).ok_or_else(|| {
anyhow::anyhow!("OAuth callback query contains malformed percent escape")
})?;
out.push((high << 4) | low);
i += 3;
continue;
}
out.push(if bytes[i] == b'+' { b' ' } else { bytes[i] });
i += 1;
}
String::from_utf8(out)
.map_err(|_| anyhow::anyhow!("OAuth callback query contains invalid UTF-8"))
}
fn hex_digit(byte: u8) -> Option<u8> {
match byte {
b'0'..=b'9' => Some(byte - b'0'),
b'a'..=b'f' => Some(byte - b'a' + 10),
b'A'..=b'F' => Some(byte - b'A' + 10),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn service_login_exchange_success_failure_timeout_and_stale_generation() {
for case in [
"success",
"exchange_failure",
"cancel_exchange",
"logout",
"timeout",
] {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let (sender, receiver) = std::sync::mpsc::sync_channel(1);
if case != "timeout" {
sender.send("synthetic-code".to_string()).unwrap();
}
let cancel = AtomicBool::new(false);
let mut states = Vec::new();
let result = service_codex_login_with_exchange(
&paths,
0,
&cancel,
&receiver,
|state, _url| states.push(state),
Duration::ZERO,
|verifier, code| {
assert!(!verifier.is_empty());
assert_eq!(code, "synthetic-code");
match case {
"exchange_failure" => anyhow::bail!("synthetic-token-secret"),
"cancel_exchange" => cancel.store(true, Ordering::Relaxed),
"logout" => {
config::remove_provider_auth(&paths, "openai-codex").unwrap();
}
_ => {}
}
Ok(config::NormalizedToken {
access: "synthetic-access".into(),
refresh: Some("synthetic-refresh".into()),
expires: Some(i64::MAX),
account_id: "synthetic-account".into(),
})
},
);
let expected = match case {
"success" => "exchange_succeeded",
"exchange_failure" => "exchange_failed",
"cancel_exchange" => "cancelled",
"logout" => "auth_changed",
_ => "callback_timeout",
};
assert_eq!(result, expected);
if case == "timeout" {
assert_eq!(states, ["authorization_url", "waiting"]);
} else {
assert_eq!(
states,
[
"authorization_url",
"waiting",
"callback_received",
"callback_accepted"
]
);
}
assert_eq!(
config::read_auth(&paths)
.unwrap()
.providers
.contains_key("openai-codex"),
case == "success"
);
}
}
#[test]
fn custom_provider_reconfiguration_preserves_advanced_fields() {
let temp = TempDir::new().unwrap();
let paths =
McPaths::from_root_and_project_dir(temp.path().join("mc"), temp.path().join("repo"));
configure_custom_provider(&paths, "local", "Initial", "http://one.test/v1", "KEY").unwrap();
let mut settings = config::read_settings(&paths).unwrap();
let provider = settings.custom_providers.get_mut("local").unwrap();
provider.models_dev_provider = Some("models-dev".to_string());
provider.use_responses_endpoint = true;
provider.reasoning_protocol = config::CustomReasoningProtocol::AnthropicLike;
provider.extra_models = vec!["extra-model".to_string()];
config::write_settings(&paths, &settings).unwrap();
configure_custom_provider(&paths, "local", "Updated", "http://two.test/v1", "NEW_KEY")
.unwrap();
let reloaded = config::read_settings(&paths).unwrap();
let provider = reloaded.custom_providers.get("local").unwrap();
assert_eq!(provider.label, "Updated");
assert_eq!(provider.base_url, "http://two.test/v1");
assert_eq!(provider.api_key_env_var.as_deref(), Some("NEW_KEY"));
assert_eq!(provider.models_dev_provider.as_deref(), Some("models-dev"));
assert!(provider.use_responses_endpoint);
assert_eq!(
provider.reasoning_protocol,
config::CustomReasoningProtocol::AnthropicLike
);
assert_eq!(provider.extra_models, ["extra-model"]);
let raw = std::fs::read_to_string(&paths.settings_file).unwrap();
assert!(raw.contains("anthropic-like"));
}
#[test]
fn custom_provider_reconfiguration_does_not_copy_project_overrides() {
let temp = TempDir::new().unwrap();
let paths =
McPaths::from_root_and_project_dir(temp.path().join("mc"), temp.path().join("repo"));
configure_custom_provider(&paths, "local", "Initial", "http://one.test/v1", "KEY").unwrap();
std::fs::create_dir_all(paths.project_settings_file.parent().unwrap()).unwrap();
std::fs::write(
&paths.project_settings_file,
r#"{"custom_providers":{"local":{"label":"Project","base_url":"http://project.test/v1","reasoning_protocol":"anthropic-like","use_responses_endpoint":true,"extra_models":["project-model"]}}}"#,
)
.unwrap();
configure_custom_provider(&paths, "local", "Updated", "http://two.test/v1", "KEY").unwrap();
let provider = serde_json::from_str::<config::Settings>(
&std::fs::read_to_string(&paths.settings_file).unwrap(),
)
.unwrap()
.custom_providers
.remove("local")
.unwrap();
assert_eq!(
provider.reasoning_protocol,
config::CustomReasoningProtocol::GptLike
);
assert!(!provider.use_responses_endpoint);
assert!(provider.extra_models.is_empty());
}
#[test]
fn provider_list_includes_custom_provider() {
let list = providers();
assert_eq!(list.len(), 3);
assert_eq!(list[0].id, OPENAI_CODEX_PROVIDER);
assert_eq!(list[0].label, "OpenAI Codex");
assert_eq!(list[1].id, ANTHROPIC_PROVIDER);
assert_eq!(list[1].label, "Anthropic");
assert_eq!(list[2].id, CUSTOM_PROVIDER_LOGIN_ID);
assert_eq!(list[2].label, "Custom Provider");
}
#[test]
fn login_provider_status_label_preserves_provider_specific_vocabulary() {
let env = crate::test_support::env::env_lock();
let _saved_key = env.save("ANTHROPIC_API_KEY");
env.remove_var("ANTHROPIC_API_KEY");
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
assert_eq!(
login_provider_status_label(&paths, ANTHROPIC_PROVIDER).unwrap(),
"needs API key"
);
assert_eq!(
login_provider_status_label(&paths, OPENAI_CODEX_PROVIDER).unwrap(),
"missing"
);
assert_eq!(
login_provider_status_label(&paths, CUSTOM_PROVIDER_LOGIN_ID).unwrap(),
"configure with prompts"
);
}
#[test]
fn login_wait_timeout_is_five_minutes() {
assert_eq!(LOGIN_WAIT_TIMEOUT.as_secs(), 300);
}
#[test]
fn callback_stream_read_timeout_is_bounded() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let addr = listener.local_addr().unwrap();
let client = TcpStream::connect(addr).unwrap();
let (mut server, _) = listener.accept().unwrap();
let (tx, rx) = std::sync::mpsc::channel();
let handle = std::thread::spawn(move || {
let result = handle_callback_stream(&mut server, "expected");
tx.send(result.map(|code| code.is_empty())).unwrap();
});
let timed_out = match rx.recv_timeout(Duration::from_millis(250)) {
Ok(result) => {
let error = result.unwrap_err().to_string();
assert!(!error.contains("expected"), "{error}");
false
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => true,
Err(error) => panic!("callback timeout test channel failed: {error}"),
};
drop(client);
handle.join().unwrap();
assert!(
!timed_out,
"silent OAuth callback client blocked beyond bounded read timeout"
);
}
#[test]
fn manual_fallback_disconnect_returns_promptly_when_loopback_unavailable() {
let _held_callback_port = TcpListener::bind(CALLBACK_ADDR).unwrap();
let (tx, rx) = std::sync::mpsc::channel::<String>();
drop(tx);
let cancel = AtomicBool::new(false);
let started = Instant::now();
let error = capture_loopback_or_manual_code(
"expected-state",
Duration::from_secs(30),
&cancel,
Some(&rx),
)
.unwrap_err()
.to_string();
assert!(started.elapsed() < Duration::from_millis(200), "{error}");
assert!(
error.contains("manual OAuth fallback input closed"),
"{error}"
);
assert!(!error.contains("expected-state"), "{error}");
}
#[test]
fn authorization_url_contains_codex_oauth_parameters() {
let attempt = OAuthAttempt {
verifier: "verifier".into(),
state: "state value".into(),
challenge: "challenge".into(),
};
let url = attempt.authorization_url();
assert!(url.starts_with(AUTHORIZE_URL));
assert!(url.contains("client_id=app_EMoamEEZ73f0CkXaXp7hrann"));
assert!(url.contains("redirect_uri=http%3A%2F%2Flocalhost%3A1455%2Fauth%2Fcallback"));
assert!(url.contains("scope=openid%20profile%20email%20offline_access"));
assert!(url.contains("code_challenge=challenge"));
assert!(url.contains("code_challenge_method=S256"));
assert!(url.contains("state=state%20value"));
assert!(url.contains("id_token_add_organizations=true"));
assert!(url.contains("codex_cli_simplified_flow=true"));
assert!(url.contains("originator=pi"));
}
#[test]
fn callback_parser_validates_state_and_redacts_failures() {
let code_key = "code";
assert_eq!(
parse_callback_request_line(
&format!("GET /auth/callback?{code_key}=placeholder&state=s HTTP/1.1"),
"s",
)
.unwrap(),
"placeholder"
);
assert_eq!(
parse_callback_request_line(
"GET /auth/callback?code=hello+world%21&state=s HTTP/1.1",
"s"
)
.unwrap(),
"hello world!"
);
let err = parse_callback_request_line(
&format!("GET /auth/callback?{code_key}=placeholder&state=wrong HTTP/1.1"),
"s",
)
.unwrap_err()
.to_string();
assert!(err.contains("state did not match"));
assert!(!err.contains("placeholder"));
}
#[test]
fn callback_parser_rejects_malformed_percent_encoding() {
for target in [
"GET /auth/callback?code=abc%&state=s HTTP/1.1",
"GET /auth/callback?code=abc%G0&state=s HTTP/1.1",
"GET /auth/callback?code=abc%é&state=s HTTP/1.1",
] {
let error = parse_callback_request_line(target, "s")
.unwrap_err()
.to_string();
assert!(error.contains("malformed percent"), "{error}");
assert!(!error.contains("abc"), "{error}");
}
}
#[test]
fn callback_parser_rejects_invalid_utf8_query_values() {
let error =
parse_callback_request_line("GET /auth/callback?code=%FF&state=s HTTP/1.1", "s")
.unwrap_err()
.to_string();
assert!(error.contains("invalid UTF-8"), "{error}");
assert!(!error.contains("%FF"), "{error}");
}
#[test]
fn manual_fallback_parses_redirect_url_and_validates_state() {
assert_eq!(
parse_manual_fallback_input(
"http://localhost:1455/auth/callback?code=manual-code&state=expected",
"expected",
)
.unwrap(),
"manual-code"
);
assert_eq!(
parse_manual_fallback_input(
"http://127.0.0.1:1455/auth/callback?code=manual-code&state=expected",
"expected",
)
.unwrap(),
"manual-code"
);
let err = parse_manual_fallback_input(
"http://127.0.0.1:1455/auth/callback?code=manual-code&state=wrong",
"expected",
)
.unwrap_err()
.to_string();
assert!(err.contains("state did not match"));
assert!(!err.contains("manual-code"));
assert_eq!(
parse_manual_fallback_input("raw-code", "expected").unwrap(),
"raw-code"
);
}
#[test]
fn provider_status_expired_without_refresh_needs_relogin() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut auth = config::Auth::default();
auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "expired-access".into(),
refresh: None,
expires: Some(Utc::now().timestamp() - 60),
account_id: Some("acct".into()),
},
);
config::write_auth(&paths, &auth).unwrap();
assert_eq!(
provider_status(&paths, OPENAI_CODEX_PROVIDER).unwrap(),
LoginProviderStatus::NeedsRelogin
);
}
#[test]
fn provider_status_reports_refreshable_codex_as_configured() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut auth = config::Auth::default();
auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: String::new(),
refresh: Some("refresh".into()),
expires: Some(Utc::now().timestamp() - 60),
account_id: Some("acct".into()),
},
);
config::write_auth(&paths, &auth).unwrap();
assert_eq!(
provider_status(&paths, OPENAI_CODEX_PROVIDER).unwrap(),
LoginProviderStatus::Configured
);
}
#[test]
fn provider_status_reports_auth_store_errors_distinctly() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
std::fs::create_dir_all(&paths.root).unwrap();
std::fs::write(&paths.auth_file, "not json").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(&paths.auth_file).unwrap().permissions();
permissions.set_mode(0o600);
std::fs::set_permissions(&paths.auth_file, permissions).unwrap();
}
let error = provider_status(&paths, OPENAI_CODEX_PROVIDER)
.unwrap_err()
.to_string();
assert!(error.contains("could not read auth store"), "{error}");
assert!(
error.contains("expected") || error.contains("key"),
"{error}"
);
}
#[test]
fn provider_status_missing_and_needs_relogin_remain_distinct() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
assert_eq!(
provider_status(&paths, OPENAI_CODEX_PROVIDER).unwrap(),
LoginProviderStatus::Missing
);
let mut auth = config::Auth::default();
auth.providers.insert(
OPENAI_CODEX_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: "access-without-account".into(),
refresh: None,
expires: Some(Utc::now().timestamp() + 3600),
account_id: None,
},
);
config::write_auth(&paths, &auth).unwrap();
assert_eq!(
provider_status(&paths, OPENAI_CODEX_PROVIDER).unwrap(),
LoginProviderStatus::NeedsRelogin
);
}
#[test]
fn logout_provider_list_matches_login_provider_ids() {
let login_ids: Vec<_> = providers()
.into_iter()
.map(|provider| provider.id)
.collect();
let logout_ids: Vec<_> = providers()
.into_iter()
.map(|provider| provider.id)
.collect();
assert_eq!(logout_ids, login_ids);
assert_eq!(
logout_ids,
vec![
OPENAI_CODEX_PROVIDER,
ANTHROPIC_PROVIDER,
CUSTOM_PROVIDER_LOGIN_ID
]
);
}
#[test]
fn logout_anthropic_status_uses_api_key_semantics() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
let mut auth = config::Auth::default();
auth.providers.insert(
ANTHROPIC_PROVIDER.to_string(),
AuthProviderRecord::ApiKey {
key: "anthropic-key".to_string(),
},
);
config::write_auth(&paths, &auth).unwrap();
assert_eq!(
logout_provider_status(&paths, ANTHROPIC_PROVIDER).unwrap(),
LogoutProviderStatus::Configured
);
auth.providers.insert(
ANTHROPIC_PROVIDER.to_string(),
AuthProviderRecord::ApiKey { key: String::new() },
);
config::write_auth(&paths, &auth).unwrap();
assert_eq!(
logout_provider_status(&paths, ANTHROPIC_PROVIDER).unwrap(),
LogoutProviderStatus::NeedsApiKey
);
auth.providers.insert(
ANTHROPIC_PROVIDER.to_string(),
AuthProviderRecord::OAuth {
access: String::new(),
refresh: None,
expires: None,
account_id: None,
},
);
config::write_auth(&paths, &auth).unwrap();
assert_eq!(
logout_provider_status(&paths, ANTHROPIC_PROVIDER).unwrap(),
LogoutProviderStatus::NeedsApiKey
);
let list = logout_provider_list_text(&paths).unwrap();
assert!(
list.contains("- Anthropic (anthropic) [needs API key]"),
"{list}"
);
assert!(list.contains("/logout anthropic"), "{list}");
let error = validate_logout_provider("unsupported")
.unwrap_err()
.to_string();
assert!(error.contains("/logout anthropic"), "{error}");
}
#[test]
fn logout_provider_status_labels_are_secret_free() {
let labels = [
LogoutProviderStatus::Missing.label(),
LogoutProviderStatus::Configured.label(),
LogoutProviderStatus::NeedsRelogin.label(),
LogoutProviderStatus::NeedsApiKey.label(),
];
for label in labels {
assert!(!label.contains("access"));
assert!(!label.contains("refresh"));
assert!(!label.contains("acct"));
}
}
#[test]
fn logout_provider_status_propagates_auth_read_errors() {
let temp = TempDir::new().unwrap();
let paths = McPaths::from_root(temp.path().join("mc"));
std::fs::create_dir_all(&paths.root).unwrap();
std::fs::write(&paths.auth_file, "not json").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut permissions = std::fs::metadata(&paths.auth_file).unwrap().permissions();
permissions.set_mode(0o600);
std::fs::set_permissions(&paths.auth_file, permissions).unwrap();
}
let error = logout_provider_status(&paths, OPENAI_CODEX_PROVIDER)
.unwrap_err()
.to_string();
assert!(
error.contains("expected") || error.contains("key"),
"{error}"
);
}
#[test]
fn debug_redacts_attempt_secrets() {
let text = format!(
"{:?}",
OAuthAttempt {
verifier: "verifier-secret".into(),
state: "state-secret".into(),
challenge: "challenge-secret".into()
}
);
assert!(!text.contains("verifier-secret"));
assert!(!text.contains("state-secret"));
assert!(!text.contains("challenge-secret"));
}
}