From 95e809c135344747008ae84c7b328399d29f1946 Mon Sep 17 00:00:00 2001 From: Ahmed Ibrahim Date: Thu, 2 Apr 2026 17:02:56 -0700 Subject: [PATCH] Auto-trust cwd on thread start (#16492) - Persist trusted cwd state during thread/start when the resolved sandbox is elevated. - Add app-server coverage for trusted root resolution and confirm turn/start does not mutate trust. --- codex-rs/app-server/README.md | 2 +- .../app-server/src/codex_message_processor.rs | 59 ++++- .../app-server/tests/suite/v2/thread_start.rs | 215 +++++++++++++++++- .../app-server/tests/suite/v2/turn_start.rs | 61 +++++ 4 files changed, 324 insertions(+), 13 deletions(-) diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 43cdbf08e..fdd61fba4 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -132,7 +132,7 @@ Example with notification opt-out: ## API Overview -- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. +- `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. - `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. - `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index e5d51dd47..7910c417c 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -235,6 +235,7 @@ use codex_features::Feature; use codex_features::Stage; use codex_feedback::CodexFeedback; use codex_git_utils::git_diff_to_remote; +use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthManager; use codex_login::AuthMode as CoreAuthMode; use codex_login::CLIENT_ID; @@ -255,6 +256,7 @@ use codex_protocol::ThreadId; use codex_protocol::config_types::CollaborationMode; use codex_protocol::config_types::ForcedLoginMethod; use codex_protocol::config_types::Personality; +use codex_protocol::config_types::TrustLevel; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::dynamic_tools::DynamicToolSpec as CoreDynamicToolSpec; use codex_protocol::items::TurnItem; @@ -2190,10 +2192,11 @@ impl CodexMessageProcessor { experimental_raw_events: bool, request_trace: Option, ) { - let config = match derive_config_from_params( + let requested_cwd = typesafe_overrides.cwd.clone(); + let mut config = match derive_config_from_params( &cli_overrides, - config_overrides, - typesafe_overrides, + config_overrides.clone(), + typesafe_overrides.clone(), &cloud_requirements, &listener_task_context.codex_home, &runtime_feature_enablement, @@ -2211,6 +2214,56 @@ impl CodexMessageProcessor { } }; + if requested_cwd.is_some() + && !config.active_project.is_trusted() + && matches!( + config.permissions.sandbox_policy.get(), + codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { .. } + | codex_protocol::protocol::SandboxPolicy::DangerFullAccess + | codex_protocol::protocol::SandboxPolicy::ExternalSandbox { .. } + ) + { + let trust_target = resolve_root_git_project_for_trust(config.cwd.as_path()) + .unwrap_or_else(|| config.cwd.to_path_buf()); + if let Err(err) = codex_core::config::set_project_trust_level( + &listener_task_context.codex_home, + trust_target.as_path(), + TrustLevel::Trusted, + ) { + let error = JSONRPCErrorError { + code: INTERNAL_ERROR_CODE, + message: format!("failed to persist trusted project state: {err}"), + data: None, + }; + listener_task_context + .outgoing + .send_error(request_id, error) + .await; + return; + } + + config = match derive_config_from_params( + &cli_overrides, + config_overrides, + typesafe_overrides, + &cloud_requirements, + &listener_task_context.codex_home, + &runtime_feature_enablement, + ) + .await + { + Ok(config) => config, + Err(err) => { + let error = config_load_error(&err); + listener_task_context + .outgoing + .send_error(request_id, error) + .await; + return; + } + }; + } + let dynamic_tools = dynamic_tools.unwrap_or_default(); let core_dynamic_tools = if dynamic_tools.is_empty() { Vec::new() 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 979c36e11..3b116f8ed 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -4,12 +4,14 @@ use app_test_support::McpProcess; use app_test_support::create_mock_responses_server_repeating_assistant; use app_test_support::to_response; use app_test_support::write_chatgpt_auth; +use codex_app_server_protocol::AskForApproval; use codex_app_server_protocol::JSONRPCError; use codex_app_server_protocol::JSONRPCMessage; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::McpServerStartupState; use codex_app_server_protocol::McpServerStatusUpdatedNotification; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ThreadStartParams; use codex_app_server_protocol::ThreadStartResponse; @@ -17,6 +19,7 @@ use codex_app_server_protocol::ThreadStartedNotification; use codex_app_server_protocol::ThreadStatus; use codex_app_server_protocol::ThreadStatusChangedNotification; use codex_core::config::set_project_trust_level; +use codex_git_utils::resolve_root_git_project_for_trust; use codex_login::AuthCredentialsStoreMode; use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::config_types::ServiceTier; @@ -48,7 +51,7 @@ async fn thread_start_creates_thread_and_emits_started() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; // Start server and initialize. let mut mcp = McpProcess::new(codex_home.path()).await?; @@ -231,7 +234,7 @@ 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())?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; let workspace = TempDir::new()?; let project_config_dir = workspace.path().join(".codex"); @@ -272,7 +275,7 @@ async fn thread_start_accepts_flex_service_tier() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -300,7 +303,7 @@ async fn thread_start_accepts_metrics_service_name() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -327,7 +330,7 @@ async fn thread_start_accepts_metrics_service_name() -> Result<()> { async fn thread_start_ephemeral_remains_pathless() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; let codex_home = TempDir::new()?; - create_config_toml(codex_home.path(), &server.uri())?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; let mut mcp = McpProcess::new(codex_home.path()).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; @@ -584,16 +587,210 @@ async fn thread_start_surfaces_cloud_requirements_load_errors() -> Result<()> { 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<()> { +#[tokio::test] +async fn thread_start_with_elevated_sandbox_trusts_project_and_followup_loads_project_config() +-> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(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" +"#, + )?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let first_request = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_request)), + ) + .await??; + + let second_request = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let second_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_request)), + ) + .await??; + let ThreadStartResponse { + approval_policy, + reasoning_effort, + .. + } = to_response::(second_response)?; + + assert_eq!(approval_policy, AskForApproval::OnRequest); + assert_eq!(reasoning_effort, Some(ReasoningEffort::High)); + + let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + let trusted_root = resolve_root_git_project_for_trust(workspace.path()) + .unwrap_or_else(|| workspace.path().to_path_buf()); + assert!(config_toml.contains(&trusted_root.display().to_string())); + assert!(config_toml.contains("trust_level = \"trusted\"")); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_with_nested_git_cwd_trusts_repo_root() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let repo_root = TempDir::new()?; + std::fs::create_dir(repo_root.path().join(".git"))?; + let nested = repo_root.path().join("nested/project"); + std::fs::create_dir_all(&nested)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(nested.display().to_string()), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + let trusted_root = + resolve_root_git_project_for_trust(&nested).expect("git root should resolve"); + assert!(config_toml.contains(&trusted_root.display().to_string())); + assert!(!config_toml.contains(&nested.display().to_string())); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_with_read_only_sandbox_does_not_persist_project_trust() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(codex_home.path(), &server.uri())?; + + let workspace = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + + let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config_toml.contains("trust_level = \"trusted\"")); + assert!(!config_toml.contains(&workspace.path().display().to_string())); + + Ok(()) +} + +#[tokio::test] +async fn thread_start_skips_trust_write_when_project_is_already_trusted() -> Result<()> { + let server = create_mock_responses_server_repeating_assistant("Done").await; + + let codex_home = TempDir::new()?; + create_config_toml_without_approval_policy(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 config_before = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + let response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(request_id)), + ) + .await??; + let ThreadStartResponse { + approval_policy, + reasoning_effort, + .. + } = to_response::(response)?; + + assert_eq!(approval_policy, AskForApproval::OnRequest); + assert_eq!(reasoning_effort, Some(ReasoningEffort::High)); + + let config_after = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert_eq!(config_after, config_before); + + Ok(()) +} + +fn create_config_toml_without_approval_policy( + codex_home: &Path, + server_uri: &str, +) -> std::io::Result<()> { + create_config_toml_with_optional_approval_policy( + codex_home, server_uri, /*approval_policy*/ None, + ) +} + +fn create_config_toml_with_optional_approval_policy( + codex_home: &Path, + server_uri: &str, + approval_policy: Option<&str>, +) -> std::io::Result<()> { let config_toml = codex_home.join("config.toml"); + let approval_policy = approval_policy + .map(|policy| format!("approval_policy = \"{policy}\"\n")) + .unwrap_or_default(); std::fs::write( config_toml, format!( r#" model = "mock-model" -approval_policy = "never" -sandbox_mode = "read-only" +{approval_policy}sandbox_mode = "read-only" model_provider = "mock_provider" diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 3155a4be9..b99d1cb73 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -2523,6 +2523,67 @@ async fn command_execution_notifications_include_process_id() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> Result<()> { + let responses = vec![create_final_assistant_message_sse_response("Done")?]; + let server = create_mock_responses_server_sequence_unchecked(responses).await; + + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + &server.uri(), + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + + let workspace = TempDir::new()?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_request = mcp + .send_thread_start_request(ThreadStartParams { + cwd: Some(workspace.path().display().to_string()), + ..Default::default() + }) + .await?; + let thread_response: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_request)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_response)?; + + let turn_request = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + cwd: Some(workspace.path().to_path_buf()), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(turn_request)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let config_toml = std::fs::read_to_string(codex_home.path().join("config.toml"))?; + assert!(!config_toml.contains("trust_level = \"trusted\"")); + assert!(!config_toml.contains(&workspace.path().display().to_string())); + + Ok(()) +} + // Helper to create a config.toml pointing at the mock model server. fn create_config_toml( codex_home: &Path,