[codex] validate bootstrap auth and redact metadata [ci changed_files]

This commit is contained in:
Cooper Gamble
2026-06-17 17:00:15 +00:00
parent 9129d0cba1
commit 482cffc020
8 changed files with 313 additions and 33 deletions
@@ -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();
@@ -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<AppsListResponse> {
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<Tool> {
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<String> {
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(
+40 -12
View File
@@ -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<WorkloadIdentityConfig> {
pub fn workload_identity_for_cloud_config(
&self,
) -> std::io::Result<Option<WorkloadIdentityConfig>> {
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)
);
}
+9 -2
View File
@@ -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(),
+37 -3
View File
@@ -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;
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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(),
+1 -1
View File
@@ -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(),