diff --git a/.github/workflows/rust-release.yml b/.github/workflows/rust-release.yml index d2c2cfdba7..4a9fa7ac61 100644 --- a/.github/workflows/rust-release.yml +++ b/.github/workflows/rust-release.yml @@ -49,7 +49,8 @@ jobs: needs: tag-check name: Build - ${{ matrix.runner }} - ${{ matrix.target }} - ${{ matrix.bundle }} runs-on: ${{ matrix.runs_on || matrix.runner }} - timeout-minutes: 60 + # Codex binaries can take a long time to build, particularly on Windows. + timeout-minutes: 90 permissions: contents: read id-token: write diff --git a/AGENTS.md b/AGENTS.md index 23f0bab387..6939d146b0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,6 +19,12 @@ In the codex-rs folder where the rust code lives: - You can run `just argument-comment-lint` to run the lint check locally. This is powered by Bazel, so running it the first time can be slow if Bazel is not warmed up, though incremental invocations should take <15s. Most of the time, it is best to update the PR and let CI take responsibility for checking this (or run it asynchronously in the background after submitting the PR). Note CI checks all three platforms, which the local run does not. - When possible, make `match` statements exhaustive and avoid wildcard arms. - Newly added traits should include doc comments that explain their role and how implementations are expected to use them. +- Discourage both `#[async_trait]` and `#[allow(async_fn_in_trait)]` in Rust traits. + - Prefer native RPITIT trait methods with explicit `Send` bounds on the returned future, as in `3c7f013f9735` / `#16630`. + - Preferred trait shape: + `fn foo(&self, ...) -> impl std::future::Future + Send;` + - Implementations may still use `async fn foo(&self, ...) -> T` when they satisfy that contract. + - Do not use `#[allow(async_fn_in_trait)]` as a shortcut around spelling the future contract explicitly. - When writing tests, prefer comparing the equality of entire objects over fields one by one. - When making a change that adds or changes an API, ensure that the documentation in the `docs/` folder is up to date if applicable. - Prefer private modules and explicitly exported public crate API. diff --git a/codex-rs/codex-mcp/src/mcp/mod.rs b/codex-rs/codex-mcp/src/mcp/mod.rs index 080ac889de..e684bf6e5a 100644 --- a/codex-rs/codex-mcp/src/mcp/mod.rs +++ b/codex-rs/codex-mcp/src/mcp/mod.rs @@ -93,6 +93,8 @@ pub fn mcp_permission_prompt_is_auto_approved( pub struct McpConfig { /// Base URL for ChatGPT-hosted app MCP servers, copied from the root config. pub chatgpt_base_url: String, + /// Optional path override for the built-in apps MCP server. + pub apps_mcp_path_override: Option, /// Codex home directory used for MCP OAuth state and app-tool cache files. pub codex_home: PathBuf, /// Preferred credential store for MCP OAuth tokens. @@ -333,7 +335,10 @@ pub async fn collect_mcp_snapshot_from_manager( } pub(crate) fn codex_apps_mcp_url(config: &McpConfig) -> String { - codex_apps_mcp_url_for_base_url(&config.chatgpt_base_url) + codex_apps_mcp_url_for_base_url( + &config.chatgpt_base_url, + config.apps_mcp_path_override.as_deref(), + ) } /// The Responses API requires tool names to match `^[a-zA-Z0-9_-]+$`. @@ -376,15 +381,19 @@ fn normalize_codex_apps_base_url(base_url: &str) -> String { base_url } -fn codex_apps_mcp_url_for_base_url(base_url: &str) -> String { +fn codex_apps_mcp_url_for_base_url(base_url: &str, apps_mcp_path_override: Option<&str>) -> String { let base_url = normalize_codex_apps_base_url(base_url); - if base_url.contains("/backend-api") { - format!("{base_url}/wham/apps") + let (base_url, default_path) = if base_url.contains("/backend-api") { + (base_url, "wham/apps") } else if base_url.contains("/api/codex") { - format!("{base_url}/apps") + (base_url, "apps") } else { - format!("{base_url}/api/codex/apps") - } + (format!("{base_url}/api/codex"), "apps") + }; + let path = apps_mcp_path_override + .unwrap_or(default_path) + .trim_start_matches('/'); + format!("{base_url}/{path}") } fn codex_apps_mcp_server_config(config: &McpConfig) -> McpServerConfig { diff --git a/codex-rs/codex-mcp/src/mcp/mod_tests.rs b/codex-rs/codex-mcp/src/mcp/mod_tests.rs index 8c977c63ce..ef9fc9bfa4 100644 --- a/codex-rs/codex-mcp/src/mcp/mod_tests.rs +++ b/codex-rs/codex-mcp/src/mcp/mod_tests.rs @@ -14,6 +14,7 @@ use std::path::PathBuf; fn test_mcp_config(codex_home: PathBuf) -> McpConfig { McpConfig { chatgpt_base_url: "https://chatgpt.com".to_string(), + apps_mcp_path_override: None, codex_home, mcp_oauth_credentials_store_mode: OAuthCredentialsStoreMode::default(), mcp_oauth_callback_port: None, @@ -109,19 +110,31 @@ fn tool_plugin_provenance_collects_app_and_mcp_sources() { #[test] fn codex_apps_mcp_url_for_base_url_keeps_existing_paths() { assert_eq!( - codex_apps_mcp_url_for_base_url("https://chatgpt.com/backend-api"), + codex_apps_mcp_url_for_base_url( + "https://chatgpt.com/backend-api", + /*apps_mcp_path_override*/ None, + ), "https://chatgpt.com/backend-api/wham/apps" ); assert_eq!( - codex_apps_mcp_url_for_base_url("https://chat.openai.com"), + codex_apps_mcp_url_for_base_url( + "https://chat.openai.com", + /*apps_mcp_path_override*/ None, + ), "https://chat.openai.com/backend-api/wham/apps" ); assert_eq!( - codex_apps_mcp_url_for_base_url("http://localhost:8080/api/codex"), + codex_apps_mcp_url_for_base_url( + "http://localhost:8080/api/codex", + /*apps_mcp_path_override*/ None, + ), "http://localhost:8080/api/codex/apps" ); assert_eq!( - codex_apps_mcp_url_for_base_url("http://localhost:8080"), + codex_apps_mcp_url_for_base_url( + "http://localhost:8080", + /*apps_mcp_path_override*/ None, + ), "http://localhost:8080/api/codex/apps" ); } @@ -158,6 +171,25 @@ fn codex_apps_server_config_uses_legacy_codex_apps_path() { assert_eq!(url, "https://chatgpt.com/backend-api/wham/apps"); } +#[test] +fn codex_apps_server_config_uses_configured_apps_mcp_path_override() { + let mut config = test_mcp_config(PathBuf::from("/tmp")); + config.apps_mcp_path_override = Some("/custom/mcp".to_string()); + config.apps_enabled = true; + let auth = CodexAuth::create_dummy_chatgpt_auth_for_testing(); + + let servers = with_codex_apps_mcp(HashMap::new(), Some(&auth), &config); + let server = servers + .get(CODEX_APPS_MCP_SERVER_NAME) + .expect("codex apps should be present when apps is enabled"); + let url = match &server.transport { + McpServerTransportConfig::StreamableHttp { url, .. } => url, + _ => panic!("expected streamable http transport for codex apps"), + }; + + assert_eq!(url, "https://chatgpt.com/backend-api/custom/mcp"); +} + #[tokio::test] async fn effective_mcp_servers_preserve_user_servers_and_add_codex_apps() { let codex_home = tempfile::tempdir().expect("tempdir"); diff --git a/codex-rs/config/src/schema.rs b/codex-rs/config/src/schema.rs index 91aa50addb..715822fbfe 100644 --- a/codex-rs/config/src/schema.rs +++ b/codex-rs/config/src/schema.rs @@ -34,6 +34,15 @@ pub fn features_schema(schema_gen: &mut SchemaGenerator) -> Schema { ); continue; } + if feature.id == codex_features::Feature::AppsMcpPathOverride { + validation.properties.insert( + feature.key.to_string(), + schema_gen.subschema_for::>(), + ); + continue; + } validation .properties .insert(feature.key.to_string(), schema_gen.subschema_for::()); diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index a17426d508..780d726fdf 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -218,6 +218,18 @@ }, "type": "object" }, + "AppsMcpPathOverrideConfigToml": { + "additionalProperties": false, + "properties": { + "enabled": { + "type": "boolean" + }, + "path": { + "type": "string" + } + }, + "type": "object" + }, "AskForApproval": { "description": "Determines the conditions under which the user is consulted to approve running the command proposed by Codex.", "oneOf": [ @@ -355,6 +367,9 @@ "apps": { "type": "boolean" }, + "apps_mcp_path_override": { + "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" + }, "browser_use": { "type": "boolean" }, @@ -705,6 +720,16 @@ }, "type": "object" }, + "FeatureToml_for_AppsMcpPathOverrideConfigToml": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/AppsMcpPathOverrideConfigToml" + } + ] + }, "FeatureToml_for_MultiAgentV2ConfigToml": { "anyOf": [ { @@ -3297,6 +3322,9 @@ "apps": { "type": "boolean" }, + "apps_mcp_path_override": { + "$ref": "#/definitions/FeatureToml_for_AppsMcpPathOverrideConfigToml" + }, "browser_use": { "type": "boolean" }, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index c03ad74aef..287e570c3b 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -3366,8 +3366,13 @@ async fn to_mcp_config_preserves_apps_feature_from_config() -> std::io::Result<( .await?; let plugins_manager = PluginsManager::new(codex_home.path().to_path_buf()); + config.apps_mcp_path_override = Some("/custom/mcp".to_string()); let mcp_config = config.to_mcp_config(&plugins_manager).await; assert!(mcp_config.apps_enabled); + assert_eq!( + mcp_config.apps_mcp_path_override.as_deref(), + Some("/custom/mcp") + ); let _ = config.features.disable(Feature::Apps); let mcp_config = config.to_mcp_config(&plugins_manager).await; @@ -6071,6 +6076,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + apps_mcp_path_override: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -6266,6 +6272,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + apps_mcp_path_override: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -6415,6 +6422,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { model_verbosity: None, personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + apps_mcp_path_override: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -6549,6 +6557,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { model_verbosity: Some(Verbosity::High), personality: Some(Personality::Pragmatic), chatgpt_base_url: "https://chatgpt.com/backend-api/".to_string(), + apps_mcp_path_override: None, realtime_audio: RealtimeAudioConfig::default(), experimental_realtime_start_instructions: None, experimental_realtime_ws_base_url: None, @@ -7187,6 +7196,32 @@ allow_login_shell = false Ok(()) } +#[tokio::test] +async fn config_loads_apps_mcp_path_override_from_feature_config() -> std::io::Result<()> { + let codex_home = TempDir::new()?; + let toml = r#" +model = "gpt-5.4" + +[features.apps_mcp_path_override] +path = "/custom/mcp" +"#; + let cfg: ConfigToml = + toml::from_str(toml).expect("TOML deserialization should succeed for apps MCP feature"); + + let config = Config::load_from_base_config_with_overrides( + cfg, + ConfigOverrides::default(), + codex_home.abs(), + ) + .await?; + + assert_eq!( + config.apps_mcp_path_override.as_deref(), + Some("/custom/mcp") + ); + Ok(()) +} + #[tokio::test] async fn config_loads_mcp_oauth_callback_url_from_toml() -> std::io::Result<()> { let codex_home = TempDir::new()?; diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index 9a1302e257..9ca42c5d5f 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -55,6 +55,7 @@ use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; use codex_exec_server::ExecutorFileSystem; use codex_exec_server::LOCAL_FS; +use codex_features::AppsMcpPathOverrideConfigToml; use codex_features::Feature; use codex_features::FeatureConfigSource; use codex_features::FeatureOverrides; @@ -673,6 +674,9 @@ pub struct Config { /// Base URL for requests to ChatGPT (as opposed to the OpenAI API). pub chatgpt_base_url: String, + /// Optional path override for the built-in apps MCP server. + pub apps_mcp_path_override: Option, + /// Machine-local realtime audio device preferences used by realtime voice. pub realtime_audio: RealtimeAudioConfig, @@ -1003,6 +1007,7 @@ impl Config { McpConfig { chatgpt_base_url: self.chatgpt_base_url.clone(), + apps_mcp_path_override: self.apps_mcp_path_override.clone(), codex_home: self.codex_home.to_path_buf(), mcp_oauth_credentials_store_mode: self.mcp_oauth_credentials_store_mode, mcp_oauth_callback_port: self.mcp_oauth_callback_port, @@ -1814,6 +1819,15 @@ fn multi_agent_v2_toml_config(features: Option<&FeaturesToml>) -> Option<&MultiA } } +fn apps_mcp_path_override_toml_config( + features: Option<&FeaturesToml>, +) -> Option<&AppsMcpPathOverrideConfigToml> { + match features?.apps_mcp_path_override.as_ref()? { + FeatureToml::Enabled(_) => None, + FeatureToml::Config(config) => Some(config), + } +} + pub(crate) fn resolve_web_search_mode_for_turn( web_search_mode: &Constrained, permission_profile: &PermissionProfile, @@ -2333,6 +2347,16 @@ impl Config { .unwrap_or(WebSearchMode::Cached); let web_search_config = resolve_web_search_config(&cfg, &config_profile); let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg, &config_profile); + let apps_mcp_path_override = if features.enabled(Feature::AppsMcpPathOverride) { + let base = apps_mcp_path_override_toml_config(cfg.features.as_ref()); + let profile = apps_mcp_path_override_toml_config(config_profile.features.as_ref()); + profile + .and_then(|config| config.path.as_ref()) + .or_else(|| base.and_then(|config| config.path.as_ref())) + .cloned() + } else { + None + }; let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg); let agent_roles = @@ -2840,6 +2864,7 @@ impl Config { .chatgpt_base_url .or(cfg.chatgpt_base_url) .unwrap_or("https://chatgpt.com/backend-api/".to_string()), + apps_mcp_path_override, realtime_audio: cfg .audio .map_or_else(RealtimeAudioConfig::default, |audio| RealtimeAudioConfig { diff --git a/codex-rs/features/src/feature_configs.rs b/codex-rs/features/src/feature_configs.rs index fd489db505..21c504bd8d 100644 --- a/codex-rs/features/src/feature_configs.rs +++ b/codex-rs/features/src/feature_configs.rs @@ -31,3 +31,18 @@ impl FeatureConfig for MultiAgentV2ConfigToml { self.enabled } } + +#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AppsMcpPathOverrideConfigToml { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, +} + +impl FeatureConfig for AppsMcpPathOverrideConfigToml { + fn enabled(&self) -> Option { + self.enabled.or(self.path.as_ref().map(|_| true)) + } +} diff --git a/codex-rs/features/src/lib.rs b/codex-rs/features/src/lib.rs index d997cc771f..e2f859c22f 100644 --- a/codex-rs/features/src/lib.rs +++ b/codex-rs/features/src/lib.rs @@ -16,6 +16,7 @@ use toml::Table; mod feature_configs; mod legacy; +pub use feature_configs::AppsMcpPathOverrideConfigToml; pub use feature_configs::MultiAgentV2ConfigToml; use legacy::LegacyFeatureToggles; pub use legacy::legacy_feature_keys; @@ -149,6 +150,8 @@ pub enum Feature { Apps, /// Enable MCP apps. EnableMcpApps, + /// Use the new path for the built-in apps MCP server. + AppsMcpPathOverride, /// Enable the tool_search tool for apps. ToolSearch, /// Always defer MCP tools behind tool_search instead of exposing small sets directly. @@ -557,6 +560,8 @@ pub fn is_known_feature_key(key: &str) -> bool { pub struct FeaturesToml { #[serde(default, skip_serializing_if = "Option::is_none")] pub multi_agent_v2: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub apps_mcp_path_override: Option>, /// Boolean feature toggles keyed by canonical or legacy feature name. #[serde(flatten)] entries: BTreeMap, @@ -575,6 +580,13 @@ impl FeaturesToml { if let Some(enabled) = self.multi_agent_v2.as_ref().and_then(FeatureToml::enabled) { entries.insert(Feature::MultiAgentV2.key().to_string(), enabled); } + if let Some(enabled) = self + .apps_mcp_path_override + .as_ref() + .and_then(FeatureToml::enabled) + { + entries.insert(Feature::AppsMcpPathOverride.key().to_string(), enabled); + } entries } } @@ -848,6 +860,12 @@ pub const FEATURES: &[FeatureSpec] = &[ stage: Stage::UnderDevelopment, default_enabled: false, }, + FeatureSpec { + id: Feature::AppsMcpPathOverride, + key: "apps_mcp_path_override", + stage: Stage::UnderDevelopment, + default_enabled: false, + }, FeatureSpec { id: Feature::ToolSearch, key: "tool_search", diff --git a/codex-rs/login/src/server.rs b/codex-rs/login/src/server.rs index 0c7b810184..f6987d46d9 100644 --- a/codex-rs/login/src/server.rs +++ b/codex-rs/login/src/server.rs @@ -50,6 +50,8 @@ use tracing::warn; const DEFAULT_ISSUER: &str = "https://auth.openai.com"; const DEFAULT_PORT: u16 = 1455; +// Keep in sync with the Codex CLI Hydra redirect URI allow-list. +const FALLBACK_PORT: u16 = 1457; static LOGIN_ERROR_PAGE_TEMPLATE: LazyLock