From 77cb1866124a47d11f66c68326518a74890bc818 Mon Sep 17 00:00:00 2001 From: Adam Perry Date: Sat, 13 Jun 2026 01:00:01 +0000 Subject: [PATCH] app-server: accept environment-native cwd --- .../schema/json/ClientRequest.json | 10 +- .../codex_app_server_protocol.schemas.json | 7 +- .../codex_app_server_protocol.v2.schemas.json | 7 +- .../schema/json/v2/ThreadStartParams.json | 10 +- .../schema/json/v2/TurnStartParams.json | 10 +- .../typescript/v2/TurnEnvironmentParams.ts | 8 +- .../src/protocol/v2/tests.rs | 18 +-- .../src/protocol/v2/turn.rs | 4 +- codex-rs/app-server/README.md | 2 +- codex-rs/app-server/src/request_processors.rs | 18 +++ .../request_processors/thread_processor.rs | 24 +--- .../src/request_processors/turn_processor.rs | 25 +--- .../app-server/tests/suite/v2/thread_start.rs | 3 +- .../app-server/tests/suite/v2/turn_start.rs | 5 +- codex-rs/core/src/thread_manager.rs | 47 +++++++ codex-rs/core/src/thread_manager_tests.rs | 127 ++++++++++++++++++ 16 files changed, 261 insertions(+), 64 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3ed2791c85..7812e9c202 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -78,6 +78,9 @@ } ] }, + "ApiPathString": { + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -4331,7 +4334,12 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/ApiPathString" + } + ], + "description": "Absolute cwd rendered using the selected environment's path convention." }, "environmentId": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json index eb659fda3b..6b37f7c45e 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.schemas.json @@ -19517,7 +19517,12 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/v2/ApiPathString" + } + ], + "description": "Absolute cwd rendered using the selected environment's path convention." }, "environmentId": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json index e0562760f6..f36b90fffa 100644 --- a/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json +++ b/codex-rs/app-server-protocol/schema/json/codex_app_server_protocol.v2.schemas.json @@ -17325,7 +17325,12 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/ApiPathString" + } + ], + "description": "Absolute cwd rendered using the selected environment's path convention." }, "environmentId": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json index 59e05c773b..95e4c706b9 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -5,6 +5,9 @@ "description": "A path that is guaranteed to be absolute and normalized (though it is not guaranteed to be canonicalized or exist on the filesystem).\n\nIMPORTANT: When deserializing an `AbsolutePathBuf`, a base path must be set using [AbsolutePathBufGuard::new]. If no base path is set, the deserialization will fail unless the path being deserialized is already absolute.", "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -242,7 +245,12 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/ApiPathString" + } + ], + "description": "Absolute cwd rendered using the selected environment's path convention." }, "environmentId": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json index 070944a28b..c1db63db48 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -27,6 +27,9 @@ ], "type": "string" }, + "ApiPathString": { + "type": "string" + }, "ApprovalsReviewer": { "description": "Configures who approval requests are routed to for review. Examples include sandbox escapes, blocked network access, MCP approval prompts, and ARC escalations. Defaults to `user`. `auto_review` uses a carefully prompted subagent to gather relevant context and apply a risk-based decision framework before approving or denying the request. The legacy value `guardian_subagent` is accepted for compatibility.", "enum": [ @@ -331,7 +334,12 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "allOf": [ + { + "$ref": "#/definitions/ApiPathString" + } + ], + "description": "Absolute cwd rendered using the selected environment's path convention." }, "environmentId": { "type": "string" diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts index bb981b0ac9..f5c91a9af1 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnEnvironmentParams.ts @@ -1,6 +1,10 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; +import type { ApiPathString } from "../ApiPathString"; -export type TurnEnvironmentParams = { environmentId: string, cwd: AbsolutePathBuf, }; +export type TurnEnvironmentParams = { environmentId: string, +/** + * Absolute cwd rendered using the selected environment's path convention. + */ +cwd: ApiPathString, }; diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 2567e386c8..cf5c54fa04 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3723,7 +3723,7 @@ fn thread_settings_update_params_preserve_field_level_experimental_gates() { #[test] fn turn_start_params_round_trip_environments() { - let cwd = test_absolute_path(); + let cwd = ApiPathString::new(r"C:\workspace"); let params: TurnStartParams = serde_json::from_value(json!({ "threadId": "thread_123", "input": [], @@ -3806,8 +3806,8 @@ fn turn_start_params_treat_null_or_omitted_environments_as_default() { } #[test] -fn turn_start_params_reject_relative_environment_cwd() { - let err = serde_json::from_value::(json!({ +fn turn_start_params_preserve_relative_environment_cwd_for_boundary_validation() { + let params = serde_json::from_value::(json!({ "threadId": "thread_123", "input": [], "environments": [ @@ -3817,12 +3817,14 @@ fn turn_start_params_reject_relative_environment_cwd() { } ], })) - .expect_err("relative environment cwd should fail"); + .expect("native cwd should deserialize without applying host rules"); - assert!( - err.to_string() - .contains("AbsolutePathBuf deserialized without a base path"), - "unexpected error: {err}" + assert_eq!( + params.environments, + Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: ApiPathString::new("relative"), + }]) ); } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 836c6d4e96..0767a8ce6d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -14,6 +14,7 @@ use codex_protocol::user_input::ByteRange as CoreByteRange; use codex_protocol::user_input::TextElement as CoreTextElement; use codex_protocol::user_input::UserInput as CoreUserInput; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::ApiPathString; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -38,7 +39,8 @@ pub enum TurnStatus { #[ts(export_to = "v2/")] pub struct TurnEnvironmentParams { pub environment_id: String, - pub cwd: AbsolutePathBuf, + /// Absolute cwd rendered using the selected environment's path convention. + pub cwd: ApiPathString, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index 336f769d71..706b706d2e 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -709,7 +709,7 @@ Turns attach user input (text or images) to a thread and trigger Codex generatio - `{"type":"image","url":"https://…png"}` - `{"type":"localImage","path":"/tmp/screenshot.png"}` -You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. Experimental `environments` is turn-scoped: omit it to inherit the thread's sticky environments, pass `[]` to run the turn with no environments, or pass explicit environment ids to override the sticky selection for this turn only. +You can optionally specify config overrides on the new turn. If specified, these settings become the default for subsequent turns on the same thread. `outputSchema` applies only to the current turn. Experimental `environments` is turn-scoped: omit it to inherit the thread's sticky environments, pass `[]` to run the turn with no environments, or pass explicit environment ids to override the sticky selection for this turn only. Each environment `cwd` must be absolute and use that environment's native path convention, so a Windows environment uses a value such as `C:\\workspace` even when app-server runs on Linux. `approvalsReviewer` accepts: diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index cfc411e130..c731b9a9b1 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -541,6 +541,24 @@ fn resolve_request_cwd(cwd: Option) -> Result, .transpose() } +async fn resolve_turn_environment_selections( + thread_manager: &ThreadManager, + environments: Option>, +) -> Result>, JSONRPCErrorError> { + let Some(environments) = environments else { + return Ok(None); + }; + thread_manager + .resolve_native_environment_selections( + environments + .into_iter() + .map(|environment| (environment.environment_id, environment.cwd)), + ) + .await + .map(Some) + .map_err(environment_selection_error) +} + fn resolve_runtime_workspace_roots(workspace_roots: Vec) -> Vec { let mut resolved_roots = Vec::new(); for root in workspace_roots { diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 09ff0dd698..89b7259d48 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -906,7 +906,8 @@ impl ThreadRequestProcessor { "`permissions` cannot be combined with `sandbox`", )); } - let environment_selections = self.parse_environment_selections(environments)?; + let environment_selections = + resolve_turn_environment_selections(self.thread_manager.as_ref(), environments).await?; let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, @@ -1311,27 +1312,6 @@ impl ThreadRequestProcessor { } } - fn parse_environment_selections( - &self, - environments: Option>, - ) -> Result>, JSONRPCErrorError> { - let environment_selections = environments.map(|environments| { - environments - .into_iter() - .map(|environment| TurnEnvironmentSelection { - environment_id: environment.environment_id, - cwd: PathUri::from_abs_path(&environment.cwd), - }) - .collect::>() - }); - if let Some(environment_selections) = environment_selections.as_ref() { - self.thread_manager - .validate_environment_selections(environment_selections) - .map_err(environment_selection_error)?; - } - Ok(environment_selections) - } - async fn thread_archive_inner( &self, params: ThreadArchiveParams, diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index 8d1810d87f..c15a4e05ab 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -341,27 +341,6 @@ impl TurnRequestProcessor { Ok((review_request, hint)) } - fn parse_environment_selections( - &self, - environments: Option>, - ) -> Result>, JSONRPCErrorError> { - let environment_selections = environments.map(|environments| { - environments - .into_iter() - .map(|environment| TurnEnvironmentSelection { - environment_id: environment.environment_id, - cwd: PathUri::from_abs_path(&environment.cwd), - }) - .collect::>() - }); - if let Some(environment_selections) = environment_selections.as_ref() { - self.thread_manager - .validate_environment_selections(environment_selections) - .map_err(environment_selection_error)?; - } - Ok(environment_selections) - } - async fn request_trace_context( &self, request_id: &ConnectionRequestId, @@ -433,7 +412,9 @@ impl TurnRequestProcessor { self.track_error_response(&request_id, error, /*error_type*/ None); })?; - let environment_selections = self.parse_environment_selections(params.environments)?; + let environment_selections = + resolve_turn_environment_selections(self.thread_manager.as_ref(), params.environments) + .await?; // Map v2 input items to core input items. let mapped_items: Vec = params 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 82a2b6b2c0..cbe0f07dc1 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -33,6 +33,7 @@ use codex_login::REFRESH_TOKEN_URL_OVERRIDE_ENV_VAR; use codex_protocol::config_types::SERVICE_TIER_DEFAULT_REQUEST_VALUE; use codex_protocol::config_types::TrustLevel; use codex_protocol::openai_models::ReasoningEffort; +use codex_utils_path_uri::ApiPathString; use pretty_assertions::assert_eq; use serde_json::Value; use serde_json::json; @@ -293,7 +294,7 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result .send_thread_start_request(ThreadStartParams { environments: Some(vec![TurnEnvironmentParams { environment_id: "missing".to_string(), - cwd: codex_home.path().to_path_buf().try_into()?, + cwd: ApiPathString::new(codex_home.path().to_string_lossy()), }]), ..Default::default() }) 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 6567b06be1..663a868519 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -76,6 +76,7 @@ use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::ImageDetail; use codex_protocol::openai_models::ReasoningEffort; use codex_protocol::user_input::MAX_USER_INPUT_TEXT_CHARS; +use codex_utils_path_uri::ApiPathString; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -1326,7 +1327,7 @@ async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result }], environments: Some(vec![TurnEnvironmentParams { environment_id: "missing".to_string(), - cwd: codex_home.path().to_path_buf().try_into()?, + cwd: ApiPathString::new(codex_home.path().to_string_lossy()), }]), ..Default::default() }) @@ -2744,7 +2745,7 @@ fn environment_params( .map(|id| { Ok(TurnEnvironmentParams { environment_id: (*id).to_string(), - cwd: cwd.to_path_buf().try_into()?, + cwd: ApiPathString::new(cwd.to_string_lossy()), }) }) .collect() diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 2adb713a0e..a04113ffa5 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -66,6 +66,7 @@ use codex_thread_store::ThreadStore; use codex_thread_store::ThreadStoreError; use codex_thread_store::UpdateThreadMetadataParams; use codex_utils_absolute_path::AbsolutePathBuf; +use codex_utils_path_uri::ApiPathString; use futures::StreamExt; use futures::stream::FuturesUnordered; use std::collections::HashMap; @@ -472,6 +473,52 @@ impl ThreadManager { Ok(()) } + /// Resolves environment-native cwd strings using metadata from each selected environment. + pub async fn resolve_native_environment_selections( + &self, + environments: impl IntoIterator, + ) -> CodexResult> { + let environments = environments.into_iter(); + let mut environment_ids = HashSet::new(); + let mut selections = Vec::with_capacity(environments.size_hint().0); + for (environment_id, cwd) in environments { + if !environment_ids.insert(environment_id.clone()) { + return Err(CodexErr::InvalidRequest(format!( + "duplicate turn environment id `{environment_id}`" + ))); + } + let environment = self + .state + .environment_manager + .get_environment(&environment_id) + .ok_or_else(|| { + CodexErr::InvalidRequest(format!( + "unknown turn environment id `{environment_id}`" + )) + })?; + let info = environment.info().await.map_err(|err| { + CodexErr::InvalidRequest(format!( + "failed to get info for environment `{environment_id}`: {err}" + )) + })?; + crate::shell::Shell::from_environment_shell_info(info.shell).map_err(|err| { + CodexErr::InvalidRequest(format!( + "failed to resolve shell for environment `{environment_id}`: {err}" + )) + })?; + let cwd = cwd.to_path_uri(info.path_convention).map_err(|err| { + CodexErr::InvalidRequest(format!( + "invalid cwd for environment `{environment_id}`: {err}" + )) + })?; + selections.push(TurnEnvironmentSelection { + environment_id, + cwd, + }); + } + Ok(selections) + } + pub fn get_models_manager(&self) -> SharedModelsManager { self.state.models_manager.clone() } diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index ddfa5e7011..df68a30c32 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -23,6 +23,8 @@ use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::ThreadSource; use codex_protocol::protocol::TurnStartedEvent; use codex_protocol::protocol::UserMessageEvent; +use codex_utils_path_uri::ApiPathString; +use codex_utils_path_uri::PathConvention; use codex_utils_path_uri::PathUri; use core_test_support::PathBufExt; use core_test_support::PathExt; @@ -298,6 +300,131 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() { assert!(manager.list_thread_ids().await.is_empty()); } +#[tokio::test] +async fn start_thread_rejects_explicit_local_environment_when_default_provider_is_disabled() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config().await; + config.codex_home = temp_dir.path().join("codex-home").abs(); + config.cwd = config.codex_home.abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let runtime_paths = codex_exec_server::ExecServerRuntimePaths::new( + std::env::current_exe().expect("current exe path"), + /*codex_linux_sandbox_exe*/ None, + ) + .expect("runtime paths"); + let environment_manager = Arc::new( + codex_exec_server::EnvironmentManager::create_for_tests( + Some("none".to_string()), + Some(runtime_paths), + ) + .await, + ); + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + environment_manager, + ); + + let result = manager + .start_thread_with_options(StartThreadOptions { + config: config.clone(), + initial_history: InitialHistory::New, + session_source: None, + thread_source: None, + dynamic_tools: Vec::new(), + metrics_service_name: None, + parent_trace: None, + environments: vec![TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: PathUri::from_abs_path(&config.cwd), + }], + thread_extension_init: Default::default(), + }) + .await; + let err = match result { + Ok(_) => panic!("explicit local environment should not resolve when provider is disabled"), + Err(err) => err, + }; + + assert_eq!(err.to_string(), "unknown turn environment id `local`"); + assert!(manager.list_thread_ids().await.is_empty()); +} + +#[tokio::test] +async fn native_environment_selections_use_selected_environment_metadata() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config().await; + config.codex_home = temp_dir.path().join("codex-home").abs(); + config.cwd = temp_dir.path().join("workspace").abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + std::fs::create_dir_all(&config.cwd).expect("create workspace"); + + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ); + let selections = manager + .resolve_native_environment_selections([( + "local".to_string(), + ApiPathString::new(config.cwd.display().to_string()), + )]) + .await + .expect("resolve local selection"); + + assert_eq!( + selections, + vec![TurnEnvironmentSelection { + environment_id: "local".to_string(), + cwd: PathUri::from_abs_path(&config.cwd), + }] + ); +} + +#[tokio::test] +async fn native_environment_selections_reject_duplicate_ids() { + let temp_dir = tempdir().expect("tempdir"); + let mut config = test_config().await; + config.codex_home = temp_dir.path().join("codex-home").abs(); + config.cwd = temp_dir.path().join("workspace").abs(); + std::fs::create_dir_all(&config.codex_home).expect("create codex home"); + + let manager = ThreadManager::with_models_provider_and_home_for_tests( + CodexAuth::from_api_key("dummy"), + config.model_provider.clone(), + config.codex_home.to_path_buf(), + Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()), + ); + let native_cwd = ApiPathString::new(config.cwd.display().to_string()); + let error = manager + .resolve_native_environment_selections([ + ("local".to_string(), native_cwd.clone()), + ("local".to_string(), native_cwd), + ]) + .await + .expect_err("duplicate ids must fail"); + + assert_eq!(error.to_string(), "duplicate turn environment id `local`"); + + let error = manager + .resolve_native_environment_selections([( + "local".to_string(), + ApiPathString::new("relative"), + )]) + .await + .expect_err("relative cwd must fail at the environment boundary"); + assert_eq!( + error.to_string(), + format!( + "invalid cwd for environment `local`: path `relative` is not absolute using {} path syntax", + PathConvention::native() + ) + ); +} + #[tokio::test] async fn start_thread_keeps_internal_threads_hidden_from_normal_lookups() { let temp_dir = tempdir().expect("tempdir");