diff --git a/codex-rs/app-server/src/request_processors/apps_processor.rs b/codex-rs/app-server/src/request_processors/apps_processor.rs index 076c536816..11b35516a0 100644 --- a/codex-rs/app-server/src/request_processors/apps_processor.rs +++ b/codex-rs/app-server/src/request_processors/apps_processor.rs @@ -73,29 +73,37 @@ impl AppsRequestProcessor { .set_enabled(Feature::Apps, thread.enabled(Feature::Apps)); } - let auth = self - .auth_manager - .auth() - .await - .map_err(|err| internal_error(format!("failed to resolve auth: {err}")))?; + let empty_response = || AppsListResponse { + data: Vec::new(), + next_cursor: None, + }; + if !config.features.enabled(Feature::Apps) { + return Ok(Some(empty_response())); + } + + let auth = if config.model_provider.requires_openai_auth { + if !self.auth_manager.is_external_chatgpt_auth_active() { + self.auth_manager.reload().await; + } + self.auth_manager + .auth() + .await + .map_err(|err| internal_error(format!("failed to resolve auth: {err}")))? + } else { + self.auth_manager.auth_cached() + }; if !config .features .apps_enabled_for_auth(auth.as_ref().is_some_and(CodexAuth::uses_codex_backend)) { - return Ok(Some(AppsListResponse { - data: Vec::new(), - next_cursor: None, - })); + return Ok(Some(empty_response())); } if !self .workspace_codex_plugins_enabled(&config, auth.as_ref()) .await { - return Ok(Some(AppsListResponse { - data: Vec::new(), - next_cursor: None, - })); + return Ok(Some(empty_response())); } let request = request_id.clone(); diff --git a/codex-rs/app-server/tests/suite/v2/app_list.rs b/codex-rs/app-server/tests/suite/v2/app_list.rs index b711867b0a..5948635932 100644 --- a/codex-rs/app-server/tests/suite/v2/app_list.rs +++ b/codex-rs/app-server/tests/suite/v2/app_list.rs @@ -8,7 +8,9 @@ use std::time::Duration; use anyhow::Result; use anyhow::bail; use app_test_support::ChatGptAuthFixture; +use app_test_support::ChatGptIdTokenClaims; use app_test_support::TestAppServer; +use app_test_support::encode_id_token; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; use axum::Json; @@ -55,11 +57,34 @@ use tempfile::TempDir; use tokio::net::TcpListener; use tokio::task::JoinHandle; use tokio::time::timeout; +use wiremock::Mock; +use wiremock::MockServer; +use wiremock::ResponseTemplate; +use wiremock::matchers::header; +use wiremock::matchers::method; +use wiremock::matchers::path; // Bazel CI can spend tens of seconds starting app-server subprocesses or // processing app-list RPCs under load. const DEFAULT_TIMEOUT: Duration = Duration::from_secs(60); +async fn request_apps_list(mcp: &mut TestAppServer) -> Result { + let request_id = mcp + .send_apps_list_request(AppsListParams { + limit: Some(50), + cursor: None, + thread_id: None, + force_refetch: false, + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + to_response(response) +} + #[tokio::test] async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> { let codex_home = TempDir::new()?; @@ -89,6 +114,105 @@ async fn list_apps_returns_empty_when_connectors_disabled() -> Result<()> { Ok(()) } +#[tokio::test] +async fn list_apps_disabled_does_not_refresh_workload_identity() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let token_path = codex_home.path().join("projected-workload-token"); + std::fs::write(&token_path, "external-subject-token\n")?; + write_apps_workload_identity_config( + codex_home.path(), + &server, + &token_path, + /*apps_enabled*/ false, + )?; + + let access_token = workload_identity_access_token()?; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token, + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 2, + "user_id": "user_test", + "chatgpt_account_id": "workspace_test", + "chatgpt_plan_type": "enterprise", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/bundle")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + std::fs::remove_file(&token_path)?; + tokio::time::sleep(Duration::from_secs(3)).await; + + let AppsListResponse { data, next_cursor } = request_apps_list(&mut mcp).await?; + assert!(data.is_empty()); + assert!(next_cursor.is_none()); + server.verify().await; + Ok(()) +} + +#[tokio::test] +async fn list_apps_local_provider_does_not_resolve_workload_identity() -> Result<()> { + let codex_home = TempDir::new()?; + let server = MockServer::start().await; + let token_path = codex_home.path().join("projected-workload-token"); + std::fs::write(&token_path, "external-subject-token\n")?; + write_local_provider_apps_workload_identity_config(codex_home.path(), &server, &token_path)?; + + let access_token = workload_identity_access_token()?; + Mock::given(method("POST")) + .and(path("/oauth/token")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "access_token": access_token.clone(), + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "token_type": "Bearer", + "expires_in": 3600, + "user_id": "user_test", + "chatgpt_account_id": "workspace_test", + "chatgpt_plan_type": "enterprise", + }))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/wham/config/bundle")) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({}))) + .expect(1) + .mount(&server) + .await; + Mock::given(method("GET")) + .and(path("/backend-api/accounts/workspace_test/settings")) + .and(header("authorization", format!("Bearer {access_token}"))) + .respond_with(ResponseTemplate::new(200).set_body_json(json!({ + "beta_settings": { "enable_plugins": false }, + }))) + .expect(1) + .mount(&server) + .await; + + let mut mcp = + TestAppServer::new_with_env(codex_home.path(), &[("OPENAI_API_KEY", None)]).await?; + timeout(DEFAULT_TIMEOUT, mcp.initialize()).await??; + std::fs::remove_file(&token_path)?; + + let AppsListResponse { data, next_cursor } = request_apps_list(&mut mcp).await?; + assert!(data.is_empty()); + assert!(next_cursor.is_none()); + server.verify().await; + Ok(()) +} + #[tokio::test] async fn list_apps_returns_empty_with_api_key_auth() -> Result<()> { let connectors = vec![AppInfo { @@ -1653,6 +1777,85 @@ fn connector_tool(connector_id: &str, connector_name: &str) -> Result { Ok(tool) } +fn write_apps_workload_identity_config( + codex_home: &Path, + server: &MockServer, + token_path: &Path, + apps_enabled: bool, +) -> std::io::Result<()> { + let chatgpt_base_url = toml::Value::String(format!("{}/backend-api", server.uri())); + let token_url = toml::Value::String(format!("{}/oauth/token", server.uri())); + let token_path = toml::Value::String(token_path.to_string_lossy().into_owned()); + std::fs::write( + codex_home.join("config.toml"), + format!( + r#"chatgpt_base_url = {chatgpt_base_url} + +[features] +apps = {apps_enabled} + +[workload_identity] +identity_provider_id = "idp_test" +identity_provider_mapping_id = "idpm_test" +audience = "api://codex-test" +token_url = {token_url} + +[workload_identity.credential_source] +type = "file" +path = {token_path} +"#, + ), + ) +} + +fn workload_identity_access_token() -> Result { + encode_id_token( + &ChatGptIdTokenClaims::new() + .email("workload@example.com") + .plan_type("enterprise") + .chatgpt_user_id("user_test") + .chatgpt_account_id("workspace_test"), + ) +} + +fn write_local_provider_apps_workload_identity_config( + codex_home: &Path, + server: &MockServer, + token_path: &Path, +) -> std::io::Result<()> { + let chatgpt_base_url = toml::Value::String(format!("{}/backend-api", server.uri())); + let token_url = toml::Value::String(format!("{}/oauth/token", server.uri())); + let token_path = toml::Value::String(token_path.to_string_lossy().into_owned()); + std::fs::write( + codex_home.join("config.toml"), + format!( + r#"model_provider = "local" +chatgpt_base_url = {chatgpt_base_url} + +[model_providers.local] +name = "Local test provider" +base_url = "http://127.0.0.1:9/v1" +wire_api = "responses" +requires_openai_auth = false +supports_websockets = false + +[features] +apps = true + +[workload_identity] +identity_provider_id = "idp_test" +identity_provider_mapping_id = "idpm_test" +audience = "api://codex-test" +token_url = {token_url} + +[workload_identity.credential_source] +type = "file" +path = {token_path} +"#, + ), + ) +} + fn write_connectors_config(codex_home: &std::path::Path, base_url: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); std::fs::write( diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 9ce0e0da4a..704597a267 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -729,14 +729,17 @@ pub struct GhostSnapshotToml { } impl ConfigToml { - /// Returns workload identity only when the provider selected by this raw - /// config needs OpenAI auth to fetch its cloud-managed config bundle. + /// Returns validated workload identity only when the provider selected by + /// this raw config needs OpenAI auth to fetch its cloud-managed config + /// bundle. /// /// Unknown provider IDs fail closed here; normal config loading will emit /// the more specific provider-not-found error later. - pub fn workload_identity_for_cloud_config(&self) -> Option { + pub fn workload_identity_for_cloud_config( + &self, + ) -> std::io::Result> { if self.forced_login_method == Some(ForcedLoginMethod::Api) { - return None; + return Ok(None); } let provider_id = self.model_provider.as_deref().unwrap_or(OPENAI_PROVIDER_ID); @@ -750,11 +753,17 @@ impl ConfigToml { .or_else(|| self.model_providers.get(provider_id)) .is_none_or(|provider| provider.requires_openai_auth); - if requires_openai_auth { - self.workload_identity.clone() - } else { - None + if !requires_openai_auth { + return Ok(None); } + + let Some(workload_identity) = self.workload_identity.as_ref() else { + return Ok(None); + }; + workload_identity.validate().map_err(|error| { + std::io::Error::new(std::io::ErrorKind::InvalidInput, error.to_string()) + })?; + Ok(Some(workload_identity.clone())) } /// Derive the effective permission profile from legacy sandbox config. @@ -1016,7 +1025,7 @@ mod tests { }; assert_eq!( - config.workload_identity_for_cloud_config(), + config.workload_identity_for_cloud_config().unwrap(), Some(workload_identity) ); } @@ -1037,7 +1046,7 @@ mod tests { ..Default::default() }; - assert_eq!(config.workload_identity_for_cloud_config(), None); + assert_eq!(config.workload_identity_for_cloud_config().unwrap(), None); } #[test] @@ -1048,7 +1057,26 @@ mod tests { ..Default::default() }; - assert_eq!(config.workload_identity_for_cloud_config(), None); + assert_eq!(config.workload_identity_for_cloud_config().unwrap(), None); + } + + #[test] + fn cloud_config_rejects_invalid_workload_identity_before_bootstrap() { + let mut workload_identity = workload_identity(); + workload_identity.token_url = "http://auth.example.com/oauth/token".to_string(); + let config = ConfigToml { + workload_identity: Some(workload_identity), + ..Default::default() + }; + + let error = config + .workload_identity_for_cloud_config() + .expect_err("non-loopback HTTP token URL must be rejected"); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidInput); + assert_eq!( + error.to_string(), + "workload_identity.token_url must use https or loopback http" + ); } #[test] @@ -1061,7 +1089,7 @@ mod tests { }; assert_eq!( - config.workload_identity_for_cloud_config(), + config.workload_identity_for_cloud_config().unwrap(), Some(workload_identity) ); } diff --git a/codex-rs/core/src/mcp_tool_call.rs b/codex-rs/core/src/mcp_tool_call.rs index 6b7d8db09d..64f8efd9d8 100644 --- a/codex-rs/core/src/mcp_tool_call.rs +++ b/codex-rs/core/src/mcp_tool_call.rs @@ -78,6 +78,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse; use codex_rmcp_client::ElicitationAction; use codex_rmcp_client::ElicitationResponse; use codex_rollout::state_db; +use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile; use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_output_truncation::TruncationPolicy; use codex_utils_output_truncation::truncate_text; @@ -730,9 +731,15 @@ async fn augment_mcp_tool_request_meta_with_sandbox_state( return Ok(meta); } + let permission_profile = turn_context.model_visible_permission_profile(); + let sandbox_policy = compatibility_sandbox_policy_for_permission_profile( + &permission_profile, + #[allow(deprecated)] + &turn_context.cwd, + ); let sandbox_state = serde_json::to_value(SandboxState { - permission_profile: Some(turn_context.permission_profile()), - sandbox_policy: turn_context.sandbox_policy(), + permission_profile: Some(permission_profile), + sandbox_policy, codex_linux_sandbox_exe: turn_context.config.codex_linux_sandbox_exe.clone(), #[allow(deprecated)] sandbox_cwd: turn_context.cwd.to_path_buf(), diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index 2be014b3f5..3e7061bfc8 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -38,12 +38,16 @@ use codex_protocol::openai_models::ModelVisibility; use codex_protocol::openai_models::ModelsResponse; use codex_protocol::openai_models::ReasoningEffortPreset; use codex_protocol::openai_models::TruncationPolicyConfig; +use codex_protocol::permissions::FileSystemAccessMode; +use codex_protocol::permissions::FileSystemPath; +use codex_protocol::permissions::FileSystemSandboxEntry; use codex_protocol::protocol::AskForApproval; use codex_protocol::protocol::EventMsg; use codex_protocol::protocol::McpInvocation; use codex_protocol::protocol::McpToolCallBeginEvent; use codex_protocol::protocol::Op; use codex_protocol::user_input::UserInput; +use codex_utils_absolute_path::AbsolutePathBuf; use codex_utils_cargo_bin::cargo_bin; use codex_utils_path_uri::PathUri; use core_test_support::assert_regex_match; @@ -768,9 +772,14 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> ) .await; + let credential_root = tempdir()?; + let credential_path = + AbsolutePathBuf::try_from(credential_root.path().join("projected-workload-token"))?; + let credential_path_for_config = credential_path.clone(); let rmcp_test_server_bin = remote_aware_stdio_server_bin()?; let fixture = test_codex() .with_config(move |config| { + config.workload_identity_credential_deny_paths = vec![credential_path_for_config]; insert_mcp_server( config, server_name, @@ -786,10 +795,25 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> wait_for_mcp_server(&fixture.codex, server_name).await?; + let visible_permission_profile = PermissionProfile::read_only(); + let mut enforced_file_system_policy = visible_permission_profile.file_system_sandbox_policy(); + enforced_file_system_policy + .entries + .push(FileSystemSandboxEntry { + path: FileSystemPath::Path { + path: credential_path.clone(), + }, + access: FileSystemAccessMode::Deny, + }); + let enforced_permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( + visible_permission_profile.enforcement(), + &enforced_file_system_policy, + visible_permission_profile.network_sandbox_policy(), + ); fixture .submit_turn_with_permission_profile( "call the rmcp sandbox_meta tool", - PermissionProfile::read_only(), + enforced_permission_profile, ) .await?; @@ -815,18 +839,28 @@ async fn stdio_mcp_tool_call_includes_sandbox_state_meta() -> anyhow::Result<()> let sandbox_meta = meta .get(MCP_SANDBOX_STATE_META_CAPABILITY) .expect("sandbox state metadata should be present"); - let (sandbox_policy, _) = - turn_permission_fields(PermissionProfile::read_only(), fixture.config.cwd.as_path()); + let (sandbox_policy, _) = turn_permission_fields( + visible_permission_profile.clone(), + fixture.config.cwd.as_path(), + ); let expected_sandbox_policy = serde_json::to_value(&sandbox_policy)?; assert_eq!( sandbox_meta.get("sandboxPolicy"), Some(&expected_sandbox_policy) ); + assert_eq!( + sandbox_meta.get("permissionProfile"), + Some(&serde_json::to_value(&visible_permission_profile)?) + ); assert_eq!( sandbox_meta.get("sandboxCwd").and_then(Value::as_str), fixture.config.cwd.as_path().to_str() ); assert_eq!(sandbox_meta.get("useLegacyLandlock"), Some(&json!(false))); + assert!( + !serde_json::to_string(sandbox_meta)?.contains(credential_path.to_string_lossy().as_ref()), + "enforcement-only workload credential path leaked into MCP metadata" + ); server.verify().await; diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 82c87c5af1..6751e7e919 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -352,7 +352,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result let workload_identity = if oss { None } else { - bootstrap_config_toml.workload_identity_for_cloud_config() + bootstrap_config_toml.workload_identity_for_cloud_config()? }; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index c35c1168b4..eb585e4ea1 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -959,7 +959,7 @@ pub async fn run_main( let workload_identity = if cli.oss { None } else { - bootstrap_config_toml.workload_identity_for_cloud_config() + bootstrap_config_toml.workload_identity_for_cloud_config()? }; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(), diff --git a/codex-rs/tui/src/session_archive_commands.rs b/codex-rs/tui/src/session_archive_commands.rs index efe2b815fe..4bc1e07d06 100644 --- a/codex-rs/tui/src/session_archive_commands.rs +++ b/codex-rs/tui/src/session_archive_commands.rs @@ -332,7 +332,7 @@ async fn start_app_server_for_archive_command( let workload_identity = if cli.oss { None } else { - config_toml.workload_identity_for_cloud_config() + config_toml.workload_identity_for_cloud_config()? }; let cloud_config_bundle = cloud_config_bundle_loader_for_storage( codex_home.to_path_buf(),