Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 94 additions & 17 deletions src/commands/launch.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -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) => {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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!({
Expand All @@ -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<String>, 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.
///
Expand Down
103 changes: 103 additions & 0 deletions src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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,
Expand Down Expand Up @@ -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() {
Expand Down