From 51bf2b6a731ffcb19a5401141ef3f737ffba2cca Mon Sep 17 00:00:00 2001 From: wpdev-mac-bashar Date: Mon, 29 Sep 2025 14:32:18 +0600 Subject: [PATCH 1/2] Add auth profile support to CLI --- codex-rs/Cargo.lock | 1 + codex-rs/cli/Cargo.toml | 1 + codex-rs/cli/src/main.rs | 159 +++++++++++++++++++++++++++++++++++++-- docs/getting-started.md | 42 +++++++++++ 4 files changed, 195 insertions(+), 8 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index eb97878072c..2ae6371f4c7 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -697,6 +697,7 @@ dependencies = [ "codex-responses-api-proxy", "codex-tui", "ctor 0.5.0", + "dirs", "libc", "owo-colors", "predicates", diff --git a/codex-rs/cli/Cargo.toml b/codex-rs/cli/Cargo.toml index e61285c8228..9809915cc0a 100644 --- a/codex-rs/cli/Cargo.toml +++ b/codex-rs/cli/Cargo.toml @@ -30,6 +30,7 @@ codex-protocol-ts = { workspace = true } codex-responses-api-proxy = { workspace = true } codex-tui = { workspace = true } ctor = { workspace = true } +dirs = { workspace = true } owo-colors = { workspace = true } serde_json = { workspace = true } supports-color = { workspace = true } diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index 871966b0a77..1f5db94082b 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1,4 +1,6 @@ use anyhow::Context; +use anyhow::anyhow; +use anyhow::bail; use clap::CommandFactory; use clap::Parser; use clap_complete::Shell; @@ -18,7 +20,9 @@ use codex_exec::Cli as ExecCli; use codex_responses_api_proxy::Args as ResponsesApiProxyArgs; use codex_tui::AppExitInfo; use codex_tui::Cli as TuiCli; +use dirs::home_dir; use owo_colors::OwoColorize; +use std::fs; use std::path::PathBuf; use supports_color::Stream; @@ -28,6 +32,10 @@ mod pre_main_hardening; use crate::mcp_cmd::McpCli; use crate::proto::ProtoCli; +const CODEX_HOME_ENV_VAR: &str = "CODEX_HOME"; +const AUTH_PROFILE_ENV_VAR: &str = "CODEX_ACTIVE_AUTH_PROFILE"; +const AUTH_PROFILE_DIR: &str = "profiles"; + /// Codex CLI /// /// If no subcommand is specified, options will be forwarded to the interactive CLI. @@ -46,6 +54,11 @@ struct MultitoolCli { #[clap(flatten)] pub config_overrides: CliConfigOverrides, + /// Authentication profile name. When provided, Codex keeps login state + /// isolated under `~/.codex/profiles/` (or the equivalent CODEX_HOME). + #[arg(long = "auth-profile", value_name = "PROFILE", global = true)] + auth_profile: Option, + #[clap(flatten)] interactive: TuiCli, @@ -166,7 +179,97 @@ struct GenerateTsCommand { prettier: Option, } -fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec { +fn apply_auth_profile(raw_profile: Option<&str>) -> anyhow::Result> { + let Some(profile) = raw_profile else { + // SAFETY: Removing an environment variable is safe; no strings are involved. + unsafe { + std::env::remove_var(AUTH_PROFILE_ENV_VAR); + } + return Ok(None); + }; + + let trimmed = profile.trim(); + if trimmed.is_empty() { + bail!("Authentication profile name cannot be empty"); + } + + let base = resolve_codex_home_base()?; + let slug = profile_slug(trimmed)?; + let profile_dir = base.join(AUTH_PROFILE_DIR).join(&slug); + + fs::create_dir_all(&profile_dir).with_context(|| { + format!( + "Failed to create auth profile directory at {}", + profile_dir.display() + ) + })?; + + let canonical_dir = profile_dir.canonicalize().unwrap_or(profile_dir.clone()); + + // SAFETY: `canonical_dir` is derived from a valid `PathBuf` and `trimmed` + // originates from CLI input which cannot contain interior NUL bytes. + unsafe { + std::env::set_var(CODEX_HOME_ENV_VAR, &canonical_dir); + std::env::set_var(AUTH_PROFILE_ENV_VAR, trimmed); + } + + Ok(Some(trimmed.to_string())) +} + +fn resolve_codex_home_base() -> anyhow::Result { + if let Some(existing) = std::env::var_os(CODEX_HOME_ENV_VAR) + && !existing.is_empty() + { + return Ok(PathBuf::from(existing)); + } + + let mut home = home_dir().ok_or_else(|| anyhow!("Could not find home directory"))?; + home.push(".codex"); + Ok(home) +} + +fn profile_slug(input: &str) -> anyhow::Result { + let mut slug = String::new(); + let mut last_was_dash = true; + + for ch in input.chars() { + if ch.is_ascii_alphanumeric() { + slug.push(ch.to_ascii_lowercase()); + last_was_dash = false; + } else if !last_was_dash && !slug.is_empty() { + slug.push('-'); + last_was_dash = true; + } + } + + let slug = slug.trim_matches('-').to_string(); + if slug.is_empty() { + bail!("Authentication profile must include at least one alphanumeric character"); + } + Ok(slug) +} + +fn shell_quote(arg: &str) -> String { + if arg.is_empty() { + return "''".to_string(); + } + + if arg + .bytes() + .all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'z' | b'A'..=b'Z' | b'-' | b'_')) + { + return arg.to_string(); + } + + let escaped = arg.replace('\'', "'\\''"); + format!("'{escaped}'") +} + +fn format_exit_messages( + exit_info: AppExitInfo, + color_enabled: bool, + auth_profile: Option<&str>, +) -> Vec { let AppExitInfo { token_usage, conversation_id, @@ -182,7 +285,10 @@ fn format_exit_messages(exit_info: AppExitInfo, color_enabled: bool) -> Vec Vec) { let color_enabled = supports_color::on(Stream::Stdout).is_some(); - for line in format_exit_messages(exit_info, color_enabled) { + for line in format_exit_messages(exit_info, color_enabled, auth_profile) { println!("{line}"); } } @@ -239,10 +345,13 @@ fn main() -> anyhow::Result<()> { async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { let MultitoolCli { config_overrides: root_config_overrides, + auth_profile, mut interactive, subcommand, } = MultitoolCli::parse(); + let active_auth_profile = apply_auth_profile(auth_profile.as_deref())?; + match subcommand { None => { prepend_config_flags( @@ -250,7 +359,7 @@ async fn cli_main(codex_linux_sandbox_exe: Option) -> anyhow::Result<() root_config_overrides.clone(), ); let exit_info = codex_tui::run_main(interactive, codex_linux_sandbox_exe).await?; - print_exit_messages(exit_info); + print_exit_messages(exit_info, active_auth_profile.as_deref()); } Some(Subcommand::Exec(mut exec_cli)) => { prepend_config_flags( @@ -453,6 +562,7 @@ mod tests { let MultitoolCli { interactive, config_overrides: root_overrides, + auth_profile: _, subcommand, } = cli; @@ -488,14 +598,14 @@ mod tests { token_usage: TokenUsage::default(), conversation_id: None, }; - let lines = format_exit_messages(exit_info, false); + let lines = format_exit_messages(exit_info, false, None); assert!(lines.is_empty()); } #[test] fn format_exit_messages_includes_resume_hint_without_color() { let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000")); - let lines = format_exit_messages(exit_info, false); + let lines = format_exit_messages(exit_info, false, None); assert_eq!( lines, vec![ @@ -509,11 +619,44 @@ mod tests { #[test] fn format_exit_messages_applies_color_when_enabled() { let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000")); - let lines = format_exit_messages(exit_info, true); + let lines = format_exit_messages(exit_info, true, None); assert_eq!(lines.len(), 2); assert!(lines[1].contains("\u{1b}[36m")); } + #[test] + fn format_exit_messages_includes_auth_profile_hint() { + let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000")); + let lines = format_exit_messages(exit_info, false, Some("Personal")); + assert_eq!( + lines[1], + "To continue this session, run codex --auth-profile Personal resume 123e4567-e89b-12d3-a456-426614174000.".to_string() + ); + } + + #[test] + fn format_exit_messages_quotes_auth_profile_when_needed() { + let exit_info = sample_exit_info(Some("123e4567-e89b-12d3-a456-426614174000")); + let lines = format_exit_messages(exit_info, false, Some("Personal Account")); + assert_eq!( + lines[1], + "To continue this session, run codex --auth-profile 'Personal Account' resume 123e4567-e89b-12d3-a456-426614174000.".to_string() + ); + } + + #[test] + fn profile_slug_normalizes_name() { + assert_eq!(profile_slug("Primary Account").unwrap(), "primary-account"); + assert_eq!(profile_slug("Admin--Team").unwrap(), "admin-team"); + assert_eq!(profile_slug("Data_Sandbox").unwrap(), "data-sandbox"); + } + + #[test] + fn profile_slug_rejects_invalid_input() { + assert!(profile_slug("!!!").is_err()); + assert!(profile_slug(" ").is_err()); + } + #[test] fn resume_model_flag_applies_when_no_root_flags() { let interactive = finalize_from_args(["codex", "resume", "-m", "gpt-5-test"].as_ref()); diff --git a/docs/getting-started.md b/docs/getting-started.md index e97de6a048c..21384212b67 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -29,6 +29,48 @@ codex resume --last codex resume 7f9f9a2e-1b3c-4c7a-9b0e-123456789abc ``` +### Managing multiple authentication profiles + +If you log into Codex with more than one account, add `--auth-profile ` +to any `codex` invocation to isolate credentials and history per profile. The +CLI maps each profile to `~/.codex/profiles/`, where the slug is a +lowercase, hyphenated form of the name (for example, `"Work Account"` +becomes `work-account`). + +Using Codex without the flag keeps the legacy behaviour: state is stored in +`~/.codex` and shared across all runs. + +Examples: + +```sh +# Launch the TUI with a personal profile (stored at ~/.codex/profiles/personal) +codex --auth-profile Personal + +# Log in with a separate work account +codex --auth-profile "Work Account" login --api-key sk-... + +# Run non-interactive commands inside that profile +codex --auth-profile "Work Account" exec "npm test" + +# Resume a session saved under the same profile +codex --auth-profile "Work Account" resume --last +``` + +Exit messages now include the profile flag when one is active so you can +copy/paste the suggested command, for example: + +``` +To continue this session, run codex --auth-profile 'Work Account' resume . +``` + +When `--auth-profile` is set, Codex exports two environment variables before +doing any work: + +- `CODEX_HOME` points to the profile directory +- `CODEX_ACTIVE_AUTH_PROFILE` matches the name you passed on the command line + +Both variables revert to their defaults when the flag is omitted. + ### Running with a prompt as input You can also run Codex CLI with a prompt as input: From c56bb9db4a87ae7752c53ed8cd146cfd9b78d736 Mon Sep 17 00:00:00 2001 From: wpdev-mac-bashar Date: Mon, 29 Sep 2025 14:37:10 +0600 Subject: [PATCH 2/2] Updated Docs --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index ae04239ae6f..9759e1b0648 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ Codex CLI supports a rich set of configuration options, with preferences stored - [**Getting started**](./docs/getting-started.md) - [CLI usage](./docs/getting-started.md#cli-usage) + - [Managing multiple authentication profiles](./docs/getting-started.md#managing-multiple-authentication-profiles) - [Running with a prompt as input](./docs/getting-started.md#running-with-a-prompt-as-input) - [Example prompts](./docs/getting-started.md#example-prompts) - [Memory with AGENTS.md](./docs/getting-started.md#memory-with-agentsmd)