diff --git a/codex-rs/app-server/src/config_manager.rs b/codex-rs/app-server/src/config_manager.rs index fae113154bfb..4c7390799d45 100644 --- a/codex-rs/app-server/src/config_manager.rs +++ b/codex-rs/app-server/src/config_manager.rs @@ -27,6 +27,7 @@ use tracing::warn; #[derive(Clone)] pub(crate) struct ConfigManager { codex_home: PathBuf, + auth_home: PathBuf, cli_overrides: Arc>>, runtime_feature_enablement: Arc>>, loader_overrides: LoaderOverrides, @@ -39,6 +40,7 @@ pub(crate) struct ConfigManager { impl ConfigManager { pub(crate) fn new( codex_home: PathBuf, + auth_home: PathBuf, cli_overrides: Vec<(String, TomlValue)>, loader_overrides: LoaderOverrides, strict_config: bool, @@ -48,6 +50,7 @@ impl ConfigManager { ) -> Self { Self { codex_home, + auth_home, cli_overrides: Arc::new(RwLock::new(cli_overrides)), runtime_feature_enablement: Arc::new(RwLock::new(BTreeMap::new())), loader_overrides, @@ -168,6 +171,7 @@ impl ConfigManager { self.current_cli_overrides(), ) .await?; + config.auth_home = AbsolutePathBuf::from_absolute_path(self.auth_home.clone())?; if self.loader_overrides.user_config_path.is_some() || self.loader_overrides.user_config_profile.is_some() { @@ -240,6 +244,7 @@ impl ConfigManager { let mut config = codex_core::config::ConfigBuilder::default() .codex_home(self.codex_home.clone()) + .auth_home(self.auth_home.clone()) .cli_overrides(merged_cli_overrides) .loader_overrides(self.loader_overrides.clone()) .strict_config(self.strict_config) @@ -306,6 +311,7 @@ impl ConfigManager { cloud_config_bundle: CloudConfigBundleLoader, ) -> Self { Self::new( + codex_home.clone(), codex_home, cli_overrides, loader_overrides, diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index 448a3898511c..2a1a2484d938 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -412,6 +412,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult CloudConfigBundleLoader { let auth_manager = AuthManager::shared( - codex_home.clone(), + auth_home, enable_codex_api_key_env, credentials_store_mode, Some(chatgpt_base_url.clone()), diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 39a322e56dfd..df46b1d54e9e 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -208,6 +208,22 @@ async fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> { Ok(()) } +#[tokio::test] +async fn config_builder_auth_home_overrides_auth_storage_only() -> std::io::Result<()> { + let codex_home = tempdir()?; + let auth_home = codex_home.path().join("auth-profiles").join("work"); + let config = ConfigBuilder::without_managed_config_for_tests() + .codex_home(codex_home.path().to_path_buf()) + .auth_home(auth_home.clone()) + .build() + .await?; + + assert_eq!(config.codex_home, codex_home.abs()); + assert_eq!(config.auth_home.to_path_buf(), auth_home); + assert_eq!(AuthManagerConfig::codex_home(&config), auth_home); + Ok(()) +} + #[tokio::test] async fn load_config_loads_global_agents_instructions() -> std::io::Result<()> { let codex_home = tempdir()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 03c8f4816765..c86958e5bf21 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -852,6 +852,10 @@ pub struct Config { /// can be overridden by the `CODEX_LAB_HOME` environment variable). pub codex_home: AbsolutePathBuf, + /// Directory used for auth credential storage for this invocation. Defaults + /// to `codex_home`, but may point at an auth profile home. + pub auth_home: AbsolutePathBuf, + /// Directory where Codex stores the SQLite state DB. pub sqlite_home: PathBuf, @@ -1104,7 +1108,7 @@ pub struct TerminalResizeReflowConfig { impl AuthManagerConfig for Config { fn codex_home(&self) -> PathBuf { - self.codex_home.to_path_buf() + self.auth_home.to_path_buf() } fn cli_auth_credentials_store_mode(&self) -> AuthCredentialsStoreMode { @@ -1123,6 +1127,7 @@ impl AuthManagerConfig for Config { #[derive(Clone, Default)] pub struct ConfigBuilder { codex_home: Option, + auth_home: Option, cli_overrides: Option>, harness_overrides: Option, loader_overrides: Option, @@ -1138,6 +1143,11 @@ impl ConfigBuilder { self } + pub fn auth_home(mut self, auth_home: PathBuf) -> Self { + self.auth_home = Some(auth_home); + self + } + pub fn cli_overrides(mut self, cli_overrides: Vec<(String, TomlValue)>) -> Self { self.cli_overrides = Some(cli_overrides); self @@ -1184,6 +1194,7 @@ impl ConfigBuilder { async fn build_inner(self) -> std::io::Result { let Self { codex_home, + auth_home, cli_overrides, harness_overrides, loader_overrides, @@ -1196,6 +1207,10 @@ impl ConfigBuilder { Some(codex_home) => AbsolutePathBuf::from_absolute_path(codex_home)?, None => find_codex_home()?, }; + let auth_home = match auth_home { + Some(auth_home) => AbsolutePathBuf::from_absolute_path(auth_home)?, + None => codex_home.clone(), + }; let cli_overrides = cli_overrides.unwrap_or_default(); let mut harness_overrides = harness_overrides.unwrap_or_default(); let loader_overrides = loader_overrides.unwrap_or_default(); @@ -1274,20 +1289,23 @@ impl ConfigBuilder { lock_config_layer_stack, ) .await?; + config.auth_home = auth_home; config.config_lock_toml = Some(Arc::new(expected_lock_config)); config.config_lock_allow_codex_version_mismatch = allow_codex_version_mismatch; config.config_lock_save_fields_resolved_from_model_catalog = save_fields_resolved_from_model_catalog; return Ok(config); } - Config::load_config_with_layer_stack( + let mut config = Config::load_config_with_layer_stack( LOCAL_FS.as_ref(), config_toml, harness_overrides, codex_home, config_layer_stack, ) - .await + .await?; + config.auth_home = auth_home; + Ok(config) } #[cfg(test)] @@ -1502,7 +1520,7 @@ impl Config { .map(AbsolutePathBuf::try_from) .transpose()?; - Self::load_config_with_layer_stack( + let mut config = Self::load_config_with_layer_stack( LOCAL_FS.as_ref(), cfg, ConfigOverrides { @@ -1513,7 +1531,9 @@ impl Config { refreshed_config.codex_home.clone(), config_layer_stack, ) - .await + .await?; + config.auth_home = refreshed_config.auth_home.clone(); + Ok(config) } /// This is the preferred way to create an instance of [Config]. @@ -2673,6 +2693,7 @@ impl Config { workspace_roots: workspace_roots_override, } = overrides; let bypass_hook_trust = bypass_hook_trust.unwrap_or_default(); + let auth_home = codex_home.clone(); if bypass_hook_trust { startup_warnings.push( @@ -3543,6 +3564,7 @@ impl Config { agent_job_max_runtime_seconds, agent_interrupt_message_enabled, codex_home, + auth_home, sqlite_home, log_dir, config_lock_export_dir: cfg diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b8b87a85eca6..a57b16fb266c 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -75,6 +75,7 @@ use codex_login::AuthConfig; use codex_login::default_client::set_default_client_residency_requirement; use codex_login::default_client::set_default_originator; use codex_login::enforce_login_restrictions; +use codex_login::profile_home; use codex_model_provider_info::LMSTUDIO_OSS_PROVIDER_ID; use codex_model_provider_info::OLLAMA_OSS_PROVIDER_ID; use codex_otel::set_parent_from_context; @@ -265,6 +266,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result oss, oss_provider, config_profile_v2, + auth_profile, sandbox_mode: sandbox_mode_cli_arg, dangerously_bypass_approvals_and_sandbox, bypass_hook_trust, @@ -323,6 +325,11 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let user_config_path = config_profile_v2 .as_ref() .map(|profile_v2| resolve_profile_v2_config_path(&codex_home, profile_v2)); + let auth_home = match auth_profile.as_deref() { + Some(profile) => profile_home(&codex_home, profile) + .map_err(|err| anyhow::anyhow!("invalid --auth-profile {profile:?}: {err}"))?, + None => codex_home.to_path_buf(), + }; let loader_overrides = LoaderOverrides { user_config_path, user_config_profile: config_profile_v2, @@ -347,6 +354,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), + auth_home.clone(), /*enable_codex_api_key_env*/ false, bootstrap_config_toml .cli_auth_credentials_store @@ -435,6 +443,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let build_config = |overrides| { ConfigBuilder::default() .codex_home(codex_home.to_path_buf()) + .auth_home(auth_home.clone()) .cli_overrides(cli_kv_overrides.clone()) .harness_overrides(overrides) .loader_overrides(loader_overrides.clone()) @@ -464,7 +473,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result set_default_client_residency_requirement(config.enforce_residency.value()); if let Err(err) = enforce_login_restrictions(&AuthConfig { - codex_home: config.codex_home.to_path_buf(), + codex_home: config.auth_home.to_path_buf(), auth_credentials_store_mode: config.cli_auth_credentials_store_mode, forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index b17028bf0e34..e37db2096a07 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -49,6 +49,7 @@ use codex_login::AuthConfig; use codex_login::default_client::originator; use codex_login::default_client::set_default_client_residency_requirement; use codex_login::enforce_login_restrictions; +use codex_login::profile_home; use codex_protocol::ThreadId; use codex_protocol::config_types::AltScreenMode; use codex_protocol::config_types::SandboxMode; @@ -960,11 +961,21 @@ pub async fn run_main( launch_loader_overrides.user_config_path = Some(user_config_path); launch_loader_overrides.user_config_profile = Some(profile_v2.clone()); } + let auth_home = match cli.auth_profile.as_deref() { + Some(profile) => match profile_home(&codex_home, profile) { + Ok(auth_home) => auth_home, + Err(err) => { + eprintln!("invalid --auth-profile {profile:?}: {err}"); + std::process::exit(1); + } + }, + None => codex_home.clone(), + }; let reuse_implicit_local_daemon = can_reuse_implicit_local_daemon( &cli_kv_overrides, &launch_loader_overrides, strict_config, - cli.bypass_hook_trust, + cli.bypass_hook_trust || cli.auth_profile.is_some(), ); let default_daemon = if explicit_remote_endpoint.is_none() && reuse_implicit_local_daemon { maybe_probe_default_daemon_socket(&codex_home).await @@ -1019,6 +1030,7 @@ pub async fn run_main( .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), + auth_home.clone(), /*enable_codex_api_key_env*/ false, bootstrap_config_toml .cli_auth_credentials_store @@ -1112,6 +1124,7 @@ pub async fn run_main( loader_overrides.clone(), cloud_config_bundle.clone(), strict_config, + Some(auth_home.clone()), ) .await; @@ -1168,6 +1181,7 @@ pub async fn run_main( loader_overrides.clone(), cloud_config_bundle.clone(), strict_config, + Some(auth_home.clone()), ) .await; } @@ -1220,7 +1234,7 @@ pub async fn run_main( if !app_server_target.uses_remote_workspace() { #[allow(clippy::print_stderr)] if let Err(err) = enforce_login_restrictions(&AuthConfig { - codex_home: config.codex_home.to_path_buf(), + codex_home: config.auth_home.to_path_buf(), auth_credentials_store_mode: config.cli_auth_credentials_store_mode, forced_login_method: config.forced_login_method, forced_chatgpt_workspace_id: config.forced_chatgpt_workspace_id.clone(), @@ -1488,6 +1502,7 @@ async fn run_ratatui_app( if show_login_screen && !uses_remote_workspace { cloud_config_bundle = cloud_config_bundle_loader_for_storage( initial_config.codex_home.to_path_buf(), + initial_config.auth_home.to_path_buf(), /*enable_codex_api_key_env*/ false, initial_config.cli_auth_credentials_store_mode, initial_config.chatgpt_base_url.clone(), @@ -1506,6 +1521,7 @@ async fn run_ratatui_app( loader_overrides.clone(), cloud_config_bundle.clone(), strict_config, + Some(initial_config.auth_home.to_path_buf()), ) .await } else { @@ -1702,6 +1718,7 @@ async fn run_ratatui_app( resume_picker::SessionSelection::StartFresh ) && (cli.resume_picker || cli.fork_picker); + let session_auth_home = config.auth_home.to_path_buf(); let mut config = match &session_selection { resume_picker::SessionSelection::Resume(_) | resume_picker::SessionSelection::Fork(_) => { load_config_or_exit_with_fallback_cwd( @@ -1710,6 +1727,7 @@ async fn run_ratatui_app( loader_overrides.clone(), cloud_config_bundle.clone(), strict_config, + Some(session_auth_home.clone()), fallback_cwd, ) .await @@ -1721,6 +1739,7 @@ async fn run_ratatui_app( loader_overrides.clone(), cloud_config_bundle.clone(), strict_config, + Some(session_auth_home), ) .await } @@ -1962,6 +1981,7 @@ async fn load_config_or_exit( loader_overrides: LoaderOverrides, cloud_config_bundle: CloudConfigBundleLoader, strict_config: bool, + auth_home: Option, ) -> Config { load_config_or_exit_with_fallback_cwd( cli_kv_overrides, @@ -1969,6 +1989,7 @@ async fn load_config_or_exit( loader_overrides, cloud_config_bundle, strict_config, + auth_home, /*fallback_cwd*/ None, ) .await @@ -1980,19 +2001,21 @@ async fn load_config_or_exit_with_fallback_cwd( loader_overrides: LoaderOverrides, cloud_config_bundle: CloudConfigBundleLoader, strict_config: bool, + auth_home: Option, fallback_cwd: Option, ) -> Config { #[allow(clippy::print_stderr)] - match ConfigBuilder::default() + let mut builder = ConfigBuilder::default() .cli_overrides(cli_kv_overrides) .harness_overrides(overrides) .loader_overrides(loader_overrides) .strict_config(strict_config) .cloud_config_bundle(cloud_config_bundle) - .fallback_cwd(fallback_cwd) - .build() - .await - { + .fallback_cwd(fallback_cwd); + if let Some(auth_home) = auth_home { + builder = builder.auth_home(auth_home); + } + match builder.build().await { Ok(config) => config, Err(err) => { eprintln!("Error loading configuration: {err}"); @@ -2478,6 +2501,16 @@ mod tests { Ok(()) } + #[test] + fn auth_profile_launch_cannot_reuse_implicit_local_daemon() { + assert!(!can_reuse_implicit_local_daemon( + &[], + &LoaderOverrides::default(), + /*strict_config*/ false, + /*has_non_replayable_launch_overrides*/ true, + )); + } + #[test] fn should_load_configured_environments_for_local_daemon() -> color_eyre::Result<()> { let target = AppServerTarget::LocalDaemon { diff --git a/codex-rs/tui/src/onboarding/auth.rs b/codex-rs/tui/src/onboarding/auth.rs index e10700ed823b..1ae2e2210798 100644 --- a/codex-rs/tui/src/onboarding/auth.rs +++ b/codex-rs/tui/src/onboarding/auth.rs @@ -1034,6 +1034,7 @@ mod tests { loader_overrides: Default::default(), strict_config: false, cloud_config_bundle: cloud_config_bundle_loader_for_storage( + codex_home_path.clone(), codex_home_path.clone(), /*enable_codex_api_key_env*/ false, AuthCredentialsStoreMode::File, diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index 4e5c420f63b5..bff5dac421dc 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -262,6 +262,7 @@ async fn start_app_server_for_archive_command( .clone() .unwrap_or_else(|| "https://chatgpt.com/backend-api/".to_string()); let cloud_config_bundle = cloud_config_bundle_loader_for_storage( + codex_home.to_path_buf(), codex_home.to_path_buf(), /*enable_codex_api_key_env*/ false, config_toml.cli_auth_credentials_store.unwrap_or_default(), diff --git a/codex-rs/utils/cli/src/shared_options.rs b/codex-rs/utils/cli/src/shared_options.rs index dbe621aaa4a4..97ae12da9e36 100644 --- a/codex-rs/utils/cli/src/shared_options.rs +++ b/codex-rs/utils/cli/src/shared_options.rs @@ -34,6 +34,10 @@ pub struct SharedCliOptions { #[arg(long = "profile", short = 'p')] pub config_profile_v2: Option, + /// Use credentials from $CODEX_LAB_HOME/auth-profiles//auth.json for this invocation. + #[arg(long = "auth-profile", value_name = "NAME")] + pub auth_profile: Option, + /// Select the sandbox policy to use when executing model-generated shell /// commands. #[arg(long = "sandbox", short = 's')] @@ -72,6 +76,7 @@ impl SharedCliOptions { oss, oss_provider, config_profile_v2, + auth_profile, sandbox_mode, dangerously_bypass_approvals_and_sandbox, bypass_hook_trust, @@ -84,6 +89,7 @@ impl SharedCliOptions { oss: root_oss, oss_provider: root_oss_provider, config_profile_v2: root_config_profile_v2, + auth_profile: root_auth_profile, sandbox_mode: root_sandbox_mode, dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox, bypass_hook_trust: root_bypass_hook_trust, @@ -103,6 +109,9 @@ impl SharedCliOptions { if config_profile_v2.is_none() { config_profile_v2.clone_from(root_config_profile_v2); } + if auth_profile.is_none() { + auth_profile.clone_from(root_auth_profile); + } if sandbox_mode.is_none() { *sandbox_mode = *root_sandbox_mode; } @@ -137,6 +146,7 @@ impl SharedCliOptions { oss, oss_provider, config_profile_v2, + auth_profile, sandbox_mode, dangerously_bypass_approvals_and_sandbox, bypass_hook_trust, @@ -156,6 +166,9 @@ impl SharedCliOptions { if let Some(config_profile_v2) = config_profile_v2 { self.config_profile_v2 = Some(config_profile_v2); } + if let Some(auth_profile) = auth_profile { + self.auth_profile = Some(auth_profile); + } if subcommand_selected_sandbox_mode { self.sandbox_mode = sandbox_mode; self.dangerously_bypass_approvals_and_sandbox =