diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 0635b530e84..422660bff00 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -217,7 +217,6 @@ v2_enum_from_core!( } ); -// TODO(mbolin): Support in-repo layer. #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(tag = "type", rename_all = "camelCase")] #[ts(tag = "type")] @@ -449,6 +448,10 @@ pub enum ConfigWriteErrorCode { pub struct ConfigReadParams { #[serde(default)] pub include_layers: bool, + /// Optional working directory to resolve project config layers. If specified, + /// return the effective config as seen from that directory (i.e., including any + /// project layers between `cwd` and the project/repo root). + pub cwd: Option, } #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index a83607a6219..57a13bdf1f0 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -155,6 +155,7 @@ use codex_core::protocol::ReviewRequest; use codex_core::protocol::ReviewTarget as CoreReviewTarget; use codex_core::protocol::SessionConfiguredEvent; use codex_core::read_head_for_summary; +use codex_core::read_session_meta_line; use codex_core::sandboxing::SandboxPermissions; use codex_feedback::CodexFeedback; use codex_login::ServerOptions as LoginServerOptions; @@ -998,7 +999,7 @@ impl CodexMessageProcessor { async fn refresh_token_if_requested(&self, do_refresh: bool) { if do_refresh && let Err(err) = self.auth_manager.refresh_token().await { - tracing::warn!("failed to refresh token whilte getting account: {err}"); + tracing::warn!("failed to refresh token while getting account: {err}"); } } @@ -1741,47 +1742,6 @@ impl CodexMessageProcessor { developer_instructions, } = params; - let overrides_requested = model.is_some() - || model_provider.is_some() - || cwd.is_some() - || approval_policy.is_some() - || sandbox.is_some() - || request_overrides.is_some() - || base_instructions.is_some() - || developer_instructions.is_some(); - - let config = if overrides_requested { - let typesafe_overrides = self.build_thread_config_overrides( - model, - model_provider, - cwd, - approval_policy, - sandbox, - base_instructions, - developer_instructions, - ); - match derive_config_from_params( - &self.cli_overrides, - request_overrides, - typesafe_overrides, - ) - .await - { - Ok(config) => config, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - } - } else { - self.config.as_ref().clone() - }; - let thread_history = if let Some(history) = history { if history.is_empty() { self.send_invalid_request_error( @@ -1856,6 +1816,38 @@ impl CodexMessageProcessor { } }; + let history_cwd = thread_history.session_cwd(); + let typesafe_overrides = self.build_thread_config_overrides( + model, + model_provider, + cwd, + approval_policy, + sandbox, + base_instructions, + developer_instructions, + ); + + // Derive a Config using the same logic as new conversation, honoring overrides if provided. + let config = match derive_config_for_cwd( + &self.cli_overrides, + request_overrides, + typesafe_overrides, + history_cwd, + ) + .await + { + Ok(config) => config, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("error deriving config: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let fallback_model_provider = config.model_provider_id.clone(); match self @@ -1945,53 +1937,6 @@ impl CodexMessageProcessor { developer_instructions, } = params; - let overrides_requested = model.is_some() - || model_provider.is_some() - || cwd.is_some() - || approval_policy.is_some() - || sandbox.is_some() - || cli_overrides.is_some() - || base_instructions.is_some() - || developer_instructions.is_some(); - - let config = if overrides_requested { - let overrides = self.build_thread_config_overrides( - model, - model_provider, - cwd, - approval_policy, - sandbox, - base_instructions, - developer_instructions, - ); - - // Persist windows sandbox feature. - let mut cli_overrides = cli_overrides.unwrap_or_default(); - if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { - cli_overrides.insert( - "features.experimental_windows_sandbox".to_string(), - serde_json::json!(true), - ); - } - - match derive_config_from_params(&self.cli_overrides, Some(cli_overrides), overrides) - .await - { - Ok(config) => config, - Err(err) => { - let error = JSONRPCErrorError { - code: INVALID_REQUEST_ERROR_CODE, - message: format!("error deriving config: {err}"), - data: None, - }; - self.outgoing.send_error(request_id, error).await; - return; - } - } - } else { - self.config.as_ref().clone() - }; - let rollout_path = if let Some(path) = path { path } else { @@ -2034,6 +1979,58 @@ impl CodexMessageProcessor { } }; + let history_cwd = match read_session_meta_line(&rollout_path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = rollout_path.display(); + warn!("failed to read session metadata from rollout {rollout_path}: {err}"); + None + } + }; + + // Persist windows sandbox feature. + let mut cli_overrides = cli_overrides.unwrap_or_default(); + if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { + cli_overrides.insert( + "features.experimental_windows_sandbox".to_string(), + serde_json::json!(true), + ); + } + let request_overrides = if cli_overrides.is_empty() { + None + } else { + Some(cli_overrides) + }; + let typesafe_overrides = self.build_thread_config_overrides( + model, + model_provider, + cwd, + approval_policy, + sandbox, + base_instructions, + developer_instructions, + ); + // Derive a Config using the same logic as new conversation, honoring overrides if provided. + let config = match derive_config_for_cwd( + &self.cli_overrides, + request_overrides, + typesafe_overrides, + history_cwd, + ) + .await + { + Ok(config) => config, + Err(err) => { + let error = JSONRPCErrorError { + code: INVALID_REQUEST_ERROR_CODE, + message: format!("error deriving config: {err}"), + data: None, + }; + self.outgoing.send_error(request_id, error).await; + return; + } + }; + let fallback_model_provider = config.model_provider_id.clone(); let NewThread { @@ -2654,68 +2651,6 @@ impl CodexMessageProcessor { overrides, } = params; - // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = match overrides { - Some(overrides) => { - let NewConversationParams { - model, - model_provider, - profile, - cwd, - approval_policy, - sandbox: sandbox_mode, - config: request_overrides, - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - } = overrides; - - // Persist windows sandbox feature. - let mut request_overrides = request_overrides.unwrap_or_default(); - if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { - request_overrides.insert( - "features.experimental_windows_sandbox".to_string(), - serde_json::json!(true), - ); - } - - let typesafe_overrides = ConfigOverrides { - model, - config_profile: profile, - cwd: cwd.map(PathBuf::from), - approval_policy, - sandbox_mode, - model_provider, - codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), - base_instructions, - developer_instructions, - compact_prompt, - include_apply_patch_tool, - ..Default::default() - }; - - derive_config_from_params( - &self.cli_overrides, - Some(request_overrides), - typesafe_overrides, - ) - .await - } - None => Ok(self.config.as_ref().clone()), - }; - let config = match config { - Ok(cfg) => cfg, - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("error deriving config: {err}"), - ) - .await; - return; - } - }; - let thread_history = if let Some(path) = path { match RolloutRecorder::get_rollout_history(&path).await { Ok(initial_history) => initial_history, @@ -2781,6 +2716,76 @@ impl CodexMessageProcessor { } }; + let history_cwd = thread_history.session_cwd(); + let (typesafe_overrides, request_overrides) = match overrides { + Some(overrides) => { + let NewConversationParams { + model, + model_provider, + profile, + cwd, + approval_policy, + sandbox: sandbox_mode, + config: request_overrides, + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + } = overrides; + + // Persist windows sandbox feature. + let mut request_overrides = request_overrides.unwrap_or_default(); + if cfg!(windows) && self.config.features.enabled(Feature::WindowsSandbox) { + request_overrides.insert( + "features.experimental_windows_sandbox".to_string(), + serde_json::json!(true), + ); + } + + let typesafe_overrides = ConfigOverrides { + model, + config_profile: profile, + cwd: cwd.map(PathBuf::from), + approval_policy, + sandbox_mode, + model_provider, + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + base_instructions, + developer_instructions, + compact_prompt, + include_apply_patch_tool, + ..Default::default() + }; + (typesafe_overrides, Some(request_overrides)) + } + None => ( + ConfigOverrides { + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + ..Default::default() + }, + None, + ), + }; + + let config = match derive_config_for_cwd( + &self.cli_overrides, + request_overrides, + typesafe_overrides, + history_cwd, + ) + .await + { + Ok(cfg) => cfg, + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("error deriving config: {err}"), + ) + .await; + return; + } + }; + match self .thread_manager .resume_thread_with_history(config, thread_history, self.auth_manager.clone()) @@ -2840,7 +2845,49 @@ impl CodexMessageProcessor { } = params; // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = match overrides { + let rollout_path = if let Some(path) = path { + path + } else if let Some(conversation_id) = conversation_id { + match find_thread_path_by_id_str(&self.config.codex_home, &conversation_id.to_string()) + .await + { + Ok(Some(found_path)) => found_path, + Ok(None) => { + self.send_invalid_request_error( + request_id, + format!("no rollout found for conversation id {conversation_id}"), + ) + .await; + return; + } + Err(err) => { + self.send_invalid_request_error( + request_id, + format!("failed to locate conversation id {conversation_id}: {err}"), + ) + .await; + return; + } + } + } else { + self.send_invalid_request_error( + request_id, + "either path or conversation id must be provided".to_string(), + ) + .await; + return; + }; + + let history_cwd = match read_session_meta_line(&rollout_path).await { + Ok(meta_line) => Some(meta_line.meta.cwd), + Err(err) => { + let rollout_path = rollout_path.display(); + warn!("failed to read session metadata from rollout {rollout_path}: {err}"); + None + } + }; + + let (typesafe_overrides, request_overrides) = match overrides { Some(overrides) => { let NewConversationParams { model, @@ -2864,6 +2911,11 @@ impl CodexMessageProcessor { serde_json::json!(true), ); } + let request_overrides = if cli_overrides.is_empty() { + None + } else { + Some(cli_overrides) + }; let overrides = ConfigOverrides { model, @@ -2880,11 +2932,25 @@ impl CodexMessageProcessor { ..Default::default() }; - derive_config_from_params(&self.cli_overrides, Some(cli_overrides), overrides).await + (overrides, request_overrides) } - None => Ok(self.config.as_ref().clone()), + None => ( + ConfigOverrides { + codex_linux_sandbox_exe: self.codex_linux_sandbox_exe.clone(), + ..Default::default() + }, + None, + ), }; - let config = match config { + + let config = match derive_config_for_cwd( + &self.cli_overrides, + request_overrides, + typesafe_overrides, + history_cwd, + ) + .await + { Ok(cfg) => cfg, Err(err) => { self.send_invalid_request_error( @@ -2896,39 +2962,6 @@ impl CodexMessageProcessor { } }; - let rollout_path = if let Some(path) = path { - path - } else if let Some(conversation_id) = conversation_id { - match find_thread_path_by_id_str(&self.config.codex_home, &conversation_id.to_string()) - .await - { - Ok(Some(found_path)) => found_path, - Ok(None) => { - self.send_invalid_request_error( - request_id, - format!("no rollout found for conversation id {conversation_id}"), - ) - .await; - return; - } - Err(err) => { - self.send_invalid_request_error( - request_id, - format!("failed to locate conversation id {conversation_id}: {err}"), - ) - .await; - return; - } - } - } else { - self.send_invalid_request_error( - request_id, - "either path or conversation id must be provided".to_string(), - ) - .await; - return; - }; - let NewThread { thread_id, session_configured, @@ -4051,6 +4084,31 @@ async fn derive_config_from_params( .await } +async fn derive_config_for_cwd( + cli_overrides: &[(String, TomlValue)], + request_overrides: Option>, + typesafe_overrides: ConfigOverrides, + cwd: Option, +) -> std::io::Result { + let merged_cli_overrides = cli_overrides + .iter() + .cloned() + .chain( + request_overrides + .unwrap_or_default() + .into_iter() + .map(|(k, v)| (k, json_to_toml(v))), + ) + .collect::>(); + + codex_core::config::ConfigBuilder::default() + .cli_overrides(merged_cli_overrides) + .harness_overrides(typesafe_overrides) + .fallback_cwd(cwd) + .build() + .await +} + pub(crate) async fn read_summary_from_rollout( path: &Path, fallback_provider: &str, diff --git a/codex-rs/app-server/tests/common/mcp_process.rs b/codex-rs/app-server/tests/common/mcp_process.rs index fe236a52b27..815aac9977e 100644 --- a/codex-rs/app-server/tests/common/mcp_process.rs +++ b/codex-rs/app-server/tests/common/mcp_process.rs @@ -53,6 +53,7 @@ use codex_app_server_protocol::ThreadRollbackParams; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::TurnInterruptParams; use codex_app_server_protocol::TurnStartParams; +use codex_core::default_client::CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR; use tokio::process::Command; pub struct McpProcess { @@ -92,6 +93,7 @@ impl McpProcess { cmd.stderr(Stdio::piped()); cmd.env("CODEX_HOME", codex_home); cmd.env("RUST_LOG", "debug"); + cmd.env_remove(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR); for (k, v) in env_overrides { match v { diff --git a/codex-rs/app-server/tests/suite/v2/config_rpc.rs b/codex-rs/app-server/tests/suite/v2/config_rpc.rs index 18311d324b8..8fbe2af1863 100644 --- a/codex-rs/app-server/tests/suite/v2/config_rpc.rs +++ b/codex-rs/app-server/tests/suite/v2/config_rpc.rs @@ -18,7 +18,10 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ToolsV2; use codex_app_server_protocol::WriteStatus; +use codex_core::config::set_project_trust_level; use codex_core::config_loader::SYSTEM_CONFIG_TOML_FILE_UNIX; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::openai_models::ReasoningEffort; use codex_utils_absolute_path::AbsolutePathBuf; use pretty_assertions::assert_eq; use serde_json::json; @@ -53,6 +56,7 @@ sandbox_mode = "workspace-write" let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: true, + cwd: None, }) .await?; let resp: JSONRPCResponse = timeout( @@ -101,6 +105,7 @@ view_image = false let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: true, + cwd: None, }) .await?; let resp: JSONRPCResponse = timeout( @@ -141,6 +146,52 @@ view_image = false Ok(()) } +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn config_read_includes_project_layers_for_cwd() -> Result<()> { + let codex_home = TempDir::new()?; + write_config(&codex_home, r#"model = "gpt-user""#)?; + + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +model_reasoning_effort = "high" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + let project_config = AbsolutePathBuf::try_from(project_config_dir)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_config_read_request(ConfigReadParams { + include_layers: true, + cwd: Some(workspace.path().to_string_lossy().into_owned()), + }) + .await?; + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ConfigReadResponse { + config, origins, .. + } = to_response(resp)?; + + assert_eq!(config.model_reasoning_effort, Some(ReasoningEffort::High)); + assert_eq!( + origins.get("model_reasoning_effort").expect("origin").name, + ConfigLayerSource::Project { + dot_codex_folder: project_config + } + ); + + Ok(()) +} + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn config_read_includes_system_layer_and_overrides() -> Result<()> { let codex_home = TempDir::new()?; @@ -195,6 +246,7 @@ writable_roots = [{}] let request_id = mcp .send_config_read_request(ConfigReadParams { include_layers: true, + cwd: None, }) .await?; let resp: JSONRPCResponse = timeout( @@ -281,6 +333,7 @@ model = "gpt-old" let read_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, + cwd: None, }) .await?; let read_resp: JSONRPCResponse = timeout( @@ -315,6 +368,7 @@ model = "gpt-old" let verify_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, + cwd: None, }) .await?; let verify_resp: JSONRPCResponse = timeout( @@ -411,6 +465,7 @@ async fn config_batch_write_applies_multiple_edits() -> Result<()> { let read_id = mcp .send_config_read_request(ConfigReadParams { include_layers: false, + cwd: None, }) .await?; let read_resp: JSONRPCResponse = timeout( diff --git a/codex-rs/app-server/tests/suite/v2/thread_start.rs b/codex-rs/app-server/tests/suite/v2/thread_start.rs index eedc05cd540..36f62cf3217 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -8,6 +8,9 @@ use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; use codex_app_server_protocol::ThreadStartedNotification; +use codex_core::config::set_project_trust_level; +use codex_protocol::config_types::TrustLevel; +use codex_protocol::openai_models::ReasoningEffort; use std::path::Path; use tempfile::TempDir; use tokio::time::timeout; @@ -69,6 +72,47 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { Ok(()) } +#[tokio::test] +async fn thread_start_respects_project_config_from_cwd() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + + let workspace = TempDir::new()?; + let project_config_dir = workspace.path().join(".codex"); + std::fs::create_dir_all(&project_config_dir)?; + std::fs::write( + project_config_dir.join("config.toml"), + r#" +model_reasoning_effort = "high" +"#, + )?; + set_project_trust_level(codex_home.path(), workspace.path(), TrustLevel::Trusted)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let req_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().to_string_lossy().into_owned()), + ..Default::default() + }) + .await?; + + let resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(req_id)), + ) + .await??; + let ThreadStartResponse { + reasoning_effort, .. + } = to_response::(resp)?; + + assert_eq!(reasoning_effort, Some(ReasoningEffort::High)); + Ok(()) +} + // Helper to create a config.toml pointing at the mock model server. fn create_config_toml(codex_home: &Path, server_uri: &str) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); diff --git a/codex-rs/core/src/config/service.rs b/codex-rs/core/src/config/service.rs index b6ed9f617e4..d1a5759d8bb 100644 --- a/codex-rs/core/src/config/service.rs +++ b/codex-rs/core/src/config/service.rs @@ -135,10 +135,27 @@ impl ConfigService { &self, params: ConfigReadParams, ) -> Result { - let layers = self - .load_thread_agnostic_config() - .await - .map_err(|err| ConfigServiceError::io("failed to read configuration layers", err))?; + let layers = match params.cwd.as_deref() { + Some(cwd) => { + let cwd = AbsolutePathBuf::try_from(PathBuf::from(cwd)).map_err(|err| { + ConfigServiceError::io("failed to resolve config cwd to an absolute path", err) + })?; + crate::config::ConfigBuilder::default() + .codex_home(self.codex_home.clone()) + .cli_overrides(self.cli_overrides.clone()) + .loader_overrides(self.loader_overrides.clone()) + .fallback_cwd(Some(cwd.to_path_buf())) + .build() + .await + .map_err(|err| { + ConfigServiceError::io("failed to read configuration layers", err) + })? + .config_layer_stack + } + None => self.load_thread_agnostic_config().await.map_err(|err| { + ConfigServiceError::io("failed to read configuration layers", err) + })?, + }; let effective = layers.effective_config(); validate_config(&effective) @@ -800,6 +817,7 @@ remote_compaction = true let response = service .read(ConfigReadParams { include_layers: true, + cwd: None, }) .await .expect("response"); @@ -892,6 +910,7 @@ remote_compaction = true let read_after = service .read(ConfigReadParams { include_layers: true, + cwd: None, }) .await .expect("read"); @@ -1032,6 +1051,7 @@ remote_compaction = true let response = service .read(ConfigReadParams { include_layers: true, + cwd: None, }) .await .expect("response"); diff --git a/codex-rs/protocol/src/protocol.rs b/codex-rs/protocol/src/protocol.rs index 533fe04d881..36af6abe9a2 100644 --- a/codex-rs/protocol/src/protocol.rs +++ b/codex-rs/protocol/src/protocol.rs @@ -1423,6 +1423,14 @@ impl InitialHistory { } } + pub fn session_cwd(&self) -> Option { + match self { + InitialHistory::New => None, + InitialHistory::Resumed(resumed) => session_cwd_from_items(&resumed.history), + InitialHistory::Forked(items) => session_cwd_from_items(items), + } + } + pub fn get_rollout_items(&self) -> Vec { match self { InitialHistory::New => Vec::new(), @@ -1474,6 +1482,13 @@ impl InitialHistory { } } +fn session_cwd_from_items(items: &[RolloutItem]) -> Option { + items.iter().find_map(|item| match item { + RolloutItem::SessionMeta(meta_line) => Some(meta_line.meta.cwd.clone()), + _ => None, + }) +} + #[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq, JsonSchema, TS, Default)] #[serde(rename_all = "lowercase")] #[ts(rename_all = "lowercase")]