diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs index cda41d978c..d6f7474e49 100644 --- a/codex-rs/cli/src/main.rs +++ b/codex-rs/cli/src/main.rs @@ -1550,6 +1550,7 @@ async fn load_exec_server_remote_auth_provider( let auth = load_exec_server_remote_auth( root_config_overrides, config_profile, + /*allow_agent_identity_auth*/ true, "remote exec-server registration requires ChatGPT authentication or Agent Identity auth from CODEX_ACCESS_TOKEN", ) .await?; @@ -1566,6 +1567,7 @@ async fn load_chatgpt_exec_server_remote_auth_provider( let auth = load_exec_server_remote_auth( root_config_overrides, config_profile, + /*allow_agent_identity_auth*/ false, "remote exec-server registration requires ChatGPT authentication; run `codex login` first", ) .await?; @@ -1582,6 +1584,7 @@ async fn load_chatgpt_exec_server_remote_auth_provider( async fn load_exec_server_remote_auth( root_config_overrides: &CliConfigOverrides, config_profile: Option, + allow_agent_identity_auth: bool, missing_auth_error: &'static str, ) -> anyhow::Result { let cli_kv_overrides = root_config_overrides @@ -1595,8 +1598,12 @@ async fn load_exec_server_remote_auth( }) .build() .await?; - let auth_manager = - AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ true).await; + let auth_manager = AuthManager::shared_from_config_with_env( + &config, + /*enable_codex_api_key_env*/ false, + /*enable_codex_access_token_env*/ allow_agent_identity_auth, + ) + .await; let auth = match auth_manager.auth().await { Some(auth) => auth, diff --git a/codex-rs/exec-server/README.md b/codex-rs/exec-server/README.md index 4f23c6cc2f..041c4aff64 100644 --- a/codex-rs/exec-server/README.md +++ b/codex-rs/exec-server/README.md @@ -380,7 +380,9 @@ The crate exports: Callers must pass `ExecServerRuntimePaths` to `run_main()`. The top-level `codex exec-server` command builds these paths from the `codex` arg0 dispatch -state. +state. `RemoteExecutorConfig::new(...)` also takes the auth provider that +remote registration should use; the CLI builds that provider from Codex auth +state before starting remote mode. ## Example session diff --git a/codex-rs/login/src/auth/auth_tests.rs b/codex-rs/login/src/auth/auth_tests.rs index 63ab3e0c42..83c36b9109 100644 --- a/codex-rs/login/src/auth/auth_tests.rs +++ b/codex-rs/login/src/auth/auth_tests.rs @@ -209,6 +209,7 @@ async fn pro_account_with_no_api_key_uses_chatgpt_auth() { let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, + /*enable_codex_access_token_env*/ true, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, ) @@ -265,6 +266,7 @@ async fn loads_api_key_from_auth_json() { let auth = super::load_auth( dir.path(), /*enable_codex_api_key_env*/ false, + /*enable_codex_access_token_env*/ true, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, ) @@ -342,6 +344,7 @@ async fn refresh_failure_is_scoped_to_the_matching_auth_snapshot() { let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, + /*enable_codex_access_token_env*/ true, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, ) @@ -743,6 +746,7 @@ async fn load_auth_reads_access_token_from_env() { let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, + /*enable_codex_access_token_env*/ true, AuthCredentialsStoreMode::File, Some(&chatgpt_base_url), ) @@ -774,6 +778,7 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ true, + /*enable_codex_access_token_env*/ true, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, ) @@ -784,6 +789,85 @@ async fn load_auth_keeps_codex_api_key_env_precedence() { assert_eq!(auth.api_key(), Some("sk-env")); } +#[tokio::test] +#[serial(codex_auth_env)] +async fn load_auth_can_ignore_access_token_env_for_persisted_chatgpt() { + let codex_home = tempdir().unwrap(); + let record = agent_identity_record(WORKSPACE_ID_ALLOWED); + let agent_identity = fake_agent_identity_jwt(&record).expect("fake agent identity"); + let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); + write_auth_file( + AuthFileParams { + openai_api_key: None, + chatgpt_plan_type: Some("pro".to_string()), + chatgpt_account_id: None, + }, + codex_home.path(), + ) + .expect("failed to write auth file"); + + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + /*enable_codex_access_token_env*/ false, + AuthCredentialsStoreMode::File, + /*chatgpt_base_url*/ None, + ) + .await + .expect("persisted auth should load") + .expect("persisted auth should be present"); + + assert!(auth.is_chatgpt_auth()); +} + +#[tokio::test] +#[serial(codex_auth_env)] +async fn load_auth_can_ignore_api_key_env_for_agent_identity_selection() { + let codex_home = tempdir().unwrap(); + let expected_record = agent_identity_record(WORKSPACE_ID_ALLOWED); + let agent_identity = + signed_agent_identity_jwt(&expected_record, json!(expected_record.plan_type)) + .expect("signed agent identity"); + let server = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/agent-identities/jwks")) + .respond_with(ResponseTemplate::new(200).set_body_json(test_jwks_body())) + .expect(1) + .mount(&server) + .await; + Mock::given(method("POST")) + .and(path("/backend-api/v1/agent/agent-runtime-id/task/register")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "task_id": "task-123", + }))) + .expect(1) + .mount(&server) + .await; + let _access_token_guard = EnvVarGuard::set(CODEX_ACCESS_TOKEN_ENV_VAR, &agent_identity); + let _api_key_guard = EnvVarGuard::set(CODEX_API_KEY_ENV_VAR, "sk-env"); + + let chatgpt_base_url = format!("{}/backend-api", server.uri()); + let _authapi_guard = + EnvVarGuard::set("CODEX_AGENT_IDENTITY_AUTHAPI_BASE_URL", &chatgpt_base_url); + let auth = super::load_auth( + codex_home.path(), + /*enable_codex_api_key_env*/ false, + /*enable_codex_access_token_env*/ true, + AuthCredentialsStoreMode::File, + Some(&chatgpt_base_url), + ) + .await + .expect("env auth should load") + .expect("env auth should be present"); + + let CodexAuth::AgentIdentity(agent_identity) = auth else { + panic!("env auth should load as agent identity"); + }; + assert_eq!(agent_identity.record(), &expected_record); + assert_eq!(agent_identity.process_task_id(), "task-123"); + server.verify().await; +} + #[tokio::test] #[serial(codex_auth_env)] async fn enforce_login_restrictions_logs_out_for_method_mismatch() { @@ -1182,6 +1266,7 @@ async fn plan_type_maps_known_plan() { let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, + /*enable_codex_access_token_env*/ true, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, ) @@ -1210,6 +1295,7 @@ async fn plan_type_maps_self_serve_business_usage_based_plan() { let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, + /*enable_codex_access_token_env*/ true, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, ) @@ -1241,6 +1327,7 @@ async fn plan_type_maps_enterprise_cbp_usage_based_plan() { let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, + /*enable_codex_access_token_env*/ true, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, ) @@ -1272,6 +1359,7 @@ async fn plan_type_maps_unknown_to_unknown() { let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, + /*enable_codex_access_token_env*/ true, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, ) @@ -1300,6 +1388,7 @@ async fn missing_plan_type_maps_to_unknown() { let auth = super::load_auth( codex_home.path(), /*enable_codex_api_key_env*/ false, + /*enable_codex_access_token_env*/ true, AuthCredentialsStoreMode::File, /*chatgpt_base_url*/ None, ) diff --git a/codex-rs/login/src/auth/manager.rs b/codex-rs/login/src/auth/manager.rs index a2e4e8e0d8..0a79ec3a61 100644 --- a/codex-rs/login/src/auth/manager.rs +++ b/codex-rs/login/src/auth/manager.rs @@ -246,6 +246,7 @@ impl CodexAuth { load_auth( codex_home, /*enable_codex_api_key_env*/ false, + /*enable_codex_access_token_env*/ true, auth_credentials_store_mode, chatgpt_base_url, ) @@ -618,6 +619,7 @@ pub async fn enforce_login_restrictions(config: &AuthConfig) -> std::io::Result< let Some(auth) = load_auth( &config.codex_home, /*enable_codex_api_key_env*/ true, + /*enable_codex_access_token_env*/ true, config.auth_credentials_store_mode, config.chatgpt_base_url.as_deref(), ) @@ -731,6 +733,7 @@ fn logout_all_stores( async fn load_auth( codex_home: &Path, enable_codex_api_key_env: bool, + enable_codex_access_token_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option<&str>, ) -> std::io::Result> { @@ -761,7 +764,9 @@ async fn load_auth( return Ok(None); } - if let Some(agent_identity) = read_codex_access_token_from_env() { + if enable_codex_access_token_env + && let Some(agent_identity) = read_codex_access_token_from_env() + { return CodexAuth::from_agent_identity_jwt(&agent_identity, chatgpt_base_url) .await .map(Some); @@ -1253,6 +1258,7 @@ pub struct AuthManager { codex_home: PathBuf, inner: RwLock, enable_codex_api_key_env: bool, + enable_codex_access_token_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, forced_chatgpt_workspace_id: RwLock>>, chatgpt_base_url: Option, @@ -1286,6 +1292,10 @@ impl Debug for AuthManager { .field("codex_home", &self.codex_home) .field("inner", &self.inner) .field("enable_codex_api_key_env", &self.enable_codex_api_key_env) + .field( + "enable_codex_access_token_env", + &self.enable_codex_access_token_env, + ) .field( "auth_credentials_store_mode", &self.auth_credentials_store_mode, @@ -1310,10 +1320,28 @@ impl AuthManager { enable_codex_api_key_env: bool, auth_credentials_store_mode: AuthCredentialsStoreMode, chatgpt_base_url: Option, + ) -> Self { + Self::new_with_env( + codex_home, + enable_codex_api_key_env, + /*enable_codex_access_token_env*/ true, + auth_credentials_store_mode, + chatgpt_base_url, + ) + .await + } + + async fn new_with_env( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + enable_codex_access_token_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option, ) -> Self { let managed_auth = load_auth( &codex_home, enable_codex_api_key_env, + enable_codex_access_token_env, auth_credentials_store_mode, chatgpt_base_url.as_deref(), ) @@ -1327,6 +1355,7 @@ impl AuthManager { permanent_refresh_failure: None, }), enable_codex_api_key_env, + enable_codex_access_token_env, auth_credentials_store_mode, forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url, @@ -1346,6 +1375,7 @@ impl AuthManager { codex_home: PathBuf::from("non-existent"), inner: RwLock::new(cached), enable_codex_api_key_env: false, + enable_codex_access_token_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, @@ -1364,6 +1394,7 @@ impl AuthManager { codex_home, inner: RwLock::new(cached), enable_codex_api_key_env: false, + enable_codex_access_token_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, @@ -1380,6 +1411,7 @@ impl AuthManager { permanent_refresh_failure: None, }), enable_codex_api_key_env: false, + enable_codex_access_token_env: false, auth_credentials_store_mode: AuthCredentialsStoreMode::File, forced_chatgpt_workspace_id: RwLock::new(None), chatgpt_base_url: None, @@ -1518,6 +1550,7 @@ impl AuthManager { load_auth( &self.codex_home, self.enable_codex_api_key_env, + self.enable_codex_access_token_env, self.auth_credentials_store_mode, self.chatgpt_base_url.as_deref(), ) @@ -1602,14 +1635,49 @@ impl AuthManager { ) } + async fn shared_with_env( + codex_home: PathBuf, + enable_codex_api_key_env: bool, + enable_codex_access_token_env: bool, + auth_credentials_store_mode: AuthCredentialsStoreMode, + chatgpt_base_url: Option, + ) -> Arc { + Arc::new( + Self::new_with_env( + codex_home, + enable_codex_api_key_env, + enable_codex_access_token_env, + auth_credentials_store_mode, + chatgpt_base_url, + ) + .await, + ) + } + /// Convenience constructor returning an `Arc` wrapper from resolved config. pub async fn shared_from_config( config: &impl AuthManagerConfig, enable_codex_api_key_env: bool, ) -> Arc { - let auth_manager = Self::shared( + Self::shared_from_config_with_env( + config, + enable_codex_api_key_env, + /*enable_codex_access_token_env*/ true, + ) + .await + } + + /// Convenience constructor returning an `Arc` wrapper from resolved config, + /// with explicit control over environment-backed auth. + pub async fn shared_from_config_with_env( + config: &impl AuthManagerConfig, + enable_codex_api_key_env: bool, + enable_codex_access_token_env: bool, + ) -> Arc { + let auth_manager = Self::shared_with_env( config.codex_home(), enable_codex_api_key_env, + enable_codex_access_token_env, config.cli_auth_credentials_store_mode(), Some(config.chatgpt_base_url()), )