From 4ed9ef704f56f49b420c6f4a422e2747cb412c83 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 26 Aug 2026 01:16:19 +0000 Subject: [PATCH] feat(provider): launch Codex against a custom provider profile (PR-2) Wire provider profiles into `codex-switch launch`. When the alias names a provider profile, launch takes a separate path from the ChatGPT one: - translate the profile into `codex -c model_providers..* / model_provider / model` overrides (layered over the user's base config, so ~/.codex and its MCP servers are untouched), and - inject the API key into the Codex child process under the profile's env_key, never onto the command line. No auth.json staging/backup/restore happens for providers (there is no OAuth token), so that whole window is skipped. Auto-select (no alias) stays ChatGPT-only. Extracted ensure_codex_available() and child_exit_code() shared by both launch paths. provider.rs gains codex_config_args()/launch_env()/toml_string() (pure, unit-tested: overrides present and TOML-quoted, key absent from argv, env pair correct, escaping). Verified end-to-end with a stub codex (args + env, key not in argv) and real codex 0.149.1 (base MCP server still lists through a provider launch). Co-authored-by: xJoker --- src/commands/launch.rs | 111 ++++++++++++++++++++++++++++++++++------- src/provider.rs | 103 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 197 insertions(+), 17 deletions(-) diff --git a/src/commands/launch.rs b/src/commands/launch.rs index d6dfb47..902f433 100644 --- a/src/commands/launch.rs +++ b/src/commands/launch.rs @@ -1,4 +1,5 @@ use crate::output::{print_json, user_println}; +use crate::provider::{self, ProviderProfile}; use crate::signals::ShutdownListener; use crate::{auth, config, profile}; use anyhow::{Context, Result}; @@ -37,6 +38,17 @@ pub(crate) async fn launch_cmd( ) -> Result<()> { use std::io::IsTerminal; + // A custom API provider profile takes a separate, simpler path: it has no + // OAuth auth.json to stage, so it never touches ~/.codex/auth.json. It is + // translated into `codex -c …` overrides with the key injected via the + // environment. Auto-select (no alias) stays ChatGPT-only. + if let Some(alias) = alias + && provider::exists(alias) + { + let profile = provider::load(alias)?; + return launch_provider(profile, args, json); + } + let mut revival_hint = None; let target_alias = match alias { Some(alias) => { @@ -65,15 +77,7 @@ pub(crate) async fn launch_cmd( user_println(&super::profile::revival_hint_message(hint)); } - match std::process::Command::new("codex") - .arg("--version") - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .output() - { - Ok(_) => {} - Err(_) => anyhow::bail!("codex not found in PATH. Install: npm install -g @openai/codex"), - } + ensure_codex_available()?; let codex_auth = auth::codex_auth_path()?; // Unique per-invocation backup name (PID + timestamp): prevents two @@ -191,14 +195,7 @@ pub(crate) async fn launch_cmd( // Wait for codex to exit let status = child.wait().context("waiting for codex")?; - // Compute exit code: prefer code(), fall back to 128+signal on Unix - #[cfg(unix)] - let exit_code = status.code().unwrap_or_else(|| { - use std::os::unix::process::ExitStatusExt; - status.signal().map(|s| 128 + s).unwrap_or(1) - }); - #[cfg(not(unix))] - let exit_code = status.code().unwrap_or(1); + let exit_code = child_exit_code(&status); if json { let mut payload = serde_json::json!({ @@ -223,6 +220,86 @@ pub(crate) async fn launch_cmd( Ok(()) } +/// Verify the `codex` binary is reachable before we stage anything or spawn it. +fn ensure_codex_available() -> Result<()> { + match std::process::Command::new("codex") + .arg("--version") + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .output() + { + Ok(_) => Ok(()), + Err(_) => anyhow::bail!("codex not found in PATH. Install: npm install -g @openai/codex"), + } +} + +/// Codex's exit code, mapping a Unix signal death to `128 + signal`. +fn child_exit_code(status: &std::process::ExitStatus) -> i32 { + #[cfg(unix)] + { + status.code().unwrap_or_else(|| { + use std::os::unix::process::ExitStatusExt; + status.signal().map(|s| 128 + s).unwrap_or(1) + }) + } + #[cfg(not(unix))] + { + status.code().unwrap_or(1) + } +} + +/// Launch Codex against a custom API provider profile. +/// +/// Unlike the ChatGPT path this stages nothing: the provider is applied as +/// `codex -c …` overrides (which layer over the user's base config, preserving +/// MCP servers and everything else) and the API key is injected into the child +/// process environment under the profile's `env_key`. Nothing is written to +/// `~/.codex`, so there is no backup/restore window to guard. +fn launch_provider(profile: ProviderProfile, args: Vec, json: bool) -> Result<()> { + ensure_codex_available()?; + + let (env_name, env_value) = profile.launch_env(); + let mut codex_args = profile.codex_config_args(); + codex_args.extend(args); + + if !json { + user_println(&format!( + "Launching codex with provider '{}' ({} / {})...", + profile.alias, profile.name, profile.model + )); + } + + let mut child = std::process::Command::new("codex") + .args(&codex_args) + .env(env_name, env_value) + .stdin(std::process::Stdio::inherit()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .spawn() + .context("Failed to start codex")?; + + let status = child.wait().context("waiting for codex")?; + let exit_code = child_exit_code(&status); + + if json { + print_json(&serde_json::json!({ + "ok": status.success(), + "alias": profile.alias, + "action": "launched", + "provider": profile.provider_id, + "model": profile.model, + "exit_code": exit_code, + })); + } else { + user_println("codex exited"); + } + + if exit_code != 0 { + std::process::exit(exit_code); + } + Ok(()) +} + /// Snapshot the live auth.json into `backup` before it is overwritten by the /// staged profile. /// diff --git a/src/provider.rs b/src/provider.rs index 2660da1..c5346f5 100644 --- a/src/provider.rs +++ b/src/provider.rs @@ -165,6 +165,64 @@ impl ProviderProfile { pub fn redacted_key(&self) -> String { redact_key(&self.api_key) } + + /// The `codex -c …` override arguments that define and select this provider + /// for a single launch. These layer on top of the user's base + /// `~/.codex/config.toml` (so MCP servers and other settings are preserved) + /// and nothing is written to disk. + /// + /// The API key is intentionally **not** here — it is handed to Codex through + /// the environment (see [`launch_env`](Self::launch_env)) so it never appears + /// in argv or the process table. + pub fn codex_config_args(&self) -> Vec { + let id = &self.provider_id; + [ + format!("model_providers.{id}.name={}", toml_string(&self.name)), + format!( + "model_providers.{id}.base_url={}", + toml_string(&self.base_url) + ), + format!( + "model_providers.{id}.env_key={}", + toml_string(&self.env_key) + ), + format!( + "model_providers.{id}.wire_api={}", + toml_string(&self.wire_api) + ), + format!("model_provider={}", toml_string(id)), + format!("model={}", toml_string(&self.model)), + ] + .into_iter() + .flat_map(|kv| ["-c".to_string(), kv]) + .collect() + } + + /// The single environment override that hands Codex the API key under the + /// profile's `env_key`. Injected into the child process only. + pub fn launch_env(&self) -> (String, String) { + (self.env_key.clone(), self.api_key.clone()) + } +} + +/// Render a string as a TOML basic (quoted) string for a `codex -c key=value` +/// override, escaping the characters TOML requires. Codex parses the value part +/// as TOML, so a plain unquoted string would be misread (or rejected). +fn toml_string(value: &str) -> String { + let mut out = String::with_capacity(value.len() + 2); + out.push('"'); + for c in value.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + _ => out.push(c), + } + } + out.push('"'); + out } /// Mask a secret for display: keep the last 4 characters when long enough, @@ -365,6 +423,51 @@ mod tests { assert!(remove("openrouter").is_err(), "removing twice must error"); } + #[test] + fn codex_config_args_define_and_select_the_provider_without_the_key() { + let p = sample("openrouter"); + let args = p.codex_config_args(); + let joined = args.join(" "); + + // Every override is introduced by its own `-c`. + assert_eq!(args.iter().filter(|a| a.as_str() == "-c").count(), 6); + assert!(joined.contains(r#"model_providers.openrouter.name="OpenRouter""#)); + assert!( + joined + .contains(r#"model_providers.openrouter.base_url="https://openrouter.ai/api/v1""#) + ); + assert!( + joined.contains(r#"model_providers.openrouter.env_key="CODEX_SWITCH_OPENROUTER_KEY""#) + ); + assert!(joined.contains(r#"model_providers.openrouter.wire_api="responses""#)); + assert!(joined.contains(r#"model_provider="openrouter""#)); + assert!(joined.contains(r#"model="openai/gpt-5.3-codex""#)); + + // The secret must never travel on the command line. + assert!( + !args.iter().any(|a| a.contains("sk-secret-1234")), + "the API key must never appear in argv" + ); + } + + #[test] + fn launch_env_carries_the_key_under_the_derived_var() { + let p = sample("openrouter"); + assert_eq!( + p.launch_env(), + ( + "CODEX_SWITCH_OPENROUTER_KEY".to_string(), + "sk-secret-1234".to_string() + ) + ); + } + + #[test] + fn toml_string_quotes_and_escapes() { + assert_eq!(toml_string("OpenRouter"), r#""OpenRouter""#); + assert_eq!(toml_string(r#"a"b\c"#), r#""a\"b\\c""#); + } + #[cfg(unix)] #[test] fn saved_key_file_is_private() {