From f8850cab1d0f192a799122ff96cb27061b9366eb Mon Sep 17 00:00:00 2001 From: "Adam Perry @ OpenAI" Date: Tue, 16 Jun 2026 14:42:28 -0700 Subject: [PATCH] app-server: preserve target-native environment cwd (#28146) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why app-server may run on a different OS from the selected exec-server environment. Parsing that environment’s cwd with the Codex host’s path rules prevents thread startup. ## What Carry environment cwd values as `LegacyAppPathString` at the app-server boundary and `PathUri` internally. Existing tool-call schemas and relative-path behavior stay host-native; remaining local-only consumers convert explicitly and leave follow-up TODOs. The Wine integration test verifies app-server can start a thread and complete an ordinary turn with a Windows environment cwd from Linux. ## Validation - `bazel test //codex-rs/core/tests/remote_env_windows:smoke-test --test_output=errors` - focused app-server environment-selection and protocol schema tests - scoped Clippy for `codex-core` and `codex-app-server-protocol` --- .../schema/json/ClientRequest.json | 5 +- .../codex_app_server_protocol.schemas.json | 2 +- .../codex_app_server_protocol.v2.schemas.json | 2 +- .../schema/json/v2/ThreadStartParams.json | 5 +- .../schema/json/v2/TurnStartParams.json | 5 +- .../typescript/v2/TurnEnvironmentParams.ts | 4 +- .../src/protocol/v2/tests.rs | 30 ++----- .../src/protocol/v2/turn.rs | 3 +- codex-rs/app-server/src/request_processors.rs | 31 +++++++ .../request_processors/thread_processor.rs | 25 +----- .../src/request_processors/turn_processor.rs | 25 +----- .../app-server/tests/suite/v2/thread_start.rs | 39 +++++++- .../app-server/tests/suite/v2/turn_start.rs | 24 +++-- codex-rs/core/src/agents_md.rs | 7 +- codex-rs/core/src/agents_md_tests.rs | 2 +- .../core/src/context/environment_context.rs | 20 +++-- codex-rs/core/src/environment_selection.rs | 23 +++-- codex-rs/core/src/guardian/review_session.rs | 4 +- codex-rs/core/src/mcp_openai_file.rs | 11 ++- codex-rs/core/src/session/mcp.rs | 21 ++--- codex-rs/core/src/session/session.rs | 5 +- codex-rs/core/src/session/tests.rs | 9 +- codex-rs/core/src/session/turn.rs | 29 +++--- codex-rs/core/src/session/turn_context.rs | 28 ++---- codex-rs/core/src/shell_snapshot.rs | 4 +- codex-rs/core/src/tasks/user_shell.rs | 14 ++- codex-rs/core/src/thread_manager_tests.rs | 8 +- .../agent_jobs/spawn_agents_on_csv.rs | 11 ++- .../core/src/tools/handlers/apply_patch.rs | 11 ++- .../src/tools/handlers/extension_tools.rs | 13 ++- .../src/tools/handlers/request_permissions.rs | 10 ++- .../handlers/unified_exec/exec_command.rs | 14 ++- .../core/src/tools/handlers/view_image.rs | 14 ++- .../src/tools/runtimes/apply_patch_tests.rs | 2 +- .../core/src/tools/runtimes/unified_exec.rs | 3 +- .../core/tests/remote_env_windows/BUILD.bazel | 1 + .../remote_env_windows_test.rs | 90 ++++++++++++++----- 37 files changed, 346 insertions(+), 208 deletions(-) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 3ed2791c8..3358ea9d6 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1303,6 +1303,9 @@ ], "type": "object" }, + "LegacyAppPathString": { + "type": "string" + }, "ListMcpServerStatusParams": { "properties": { "cursor": { @@ -4331,7 +4334,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "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 97c4eee3c..1e11c76ae 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,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/v2/AbsolutePathBuf" + "$ref": "#/definitions/v2/LegacyAppPathString" }, "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 1f5e7ac2b..326bfdfc9 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,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "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 59e05c773..d4291f6dc 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -191,6 +191,9 @@ } ] }, + "LegacyAppPathString": { + "type": "string" + }, "Personality": { "enum": [ "none", @@ -242,7 +245,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "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 070944a28..b1c72bb1f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -130,6 +130,9 @@ ], "type": "string" }, + "LegacyAppPathString": { + "type": "string" + }, "ModeKind": { "description": "Initial collaboration mode to use when the TUI starts.", "enum": [ @@ -331,7 +334,7 @@ "TurnEnvironmentParams": { "properties": { "cwd": { - "$ref": "#/definitions/AbsolutePathBuf" + "$ref": "#/definitions/LegacyAppPathString" }, "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 bb981b0ac..cb93ba396 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,6 @@ // 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 { LegacyAppPathString } from "../LegacyAppPathString"; -export type TurnEnvironmentParams = { environmentId: string, cwd: AbsolutePathBuf, }; +export type TurnEnvironmentParams = { environmentId: string, cwd: LegacyAppPathString, }; 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 c69087168..de138d5c2 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,14 @@ fn thread_settings_update_params_preserve_field_level_experimental_gates() { #[test] fn turn_start_params_round_trip_environments() { - let cwd = test_absolute_path(); + // Use a path foreign to the test host so this exercises syntax preservation instead of the + // host-native conversion performed by test_absolute_path(). + #[cfg(windows)] + let raw_cwd = "/workspace"; + #[cfg(not(windows))] + let raw_cwd = r"C:\workspace"; + let cwd: LegacyAppPathString = + serde_json::from_value(json!(raw_cwd)).expect("API path should deserialize"); let params: TurnStartParams = serde_json::from_value(json!({ "threadId": "thread_123", "input": [], @@ -3805,27 +3812,6 @@ 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!({ - "threadId": "thread_123", - "input": [], - "environments": [ - { - "environmentId": "local", - "cwd": "relative" - } - ], - })) - .expect_err("relative environment cwd should fail"); - - assert!( - err.to_string() - .contains("AbsolutePathBuf deserialized without a base path"), - "unexpected error: {err}" - ); -} - #[test] fn realtime_append_text_defaults_role_to_user() { let params = serde_json::from_value::(json!({ 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 836c6d4e9..77da0ea8a 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::LegacyAppPathString; use schemars::JsonSchema; use serde::Deserialize; use serde::Serialize; @@ -38,7 +39,7 @@ pub enum TurnStatus { #[ts(export_to = "v2/")] pub struct TurnEnvironmentParams { pub environment_id: String, - pub cwd: AbsolutePathBuf, + pub cwd: LegacyAppPathString, } #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, TS)] diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index cfc411e13..b5295b9b7 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -541,6 +541,37 @@ fn resolve_request_cwd(cwd: Option) -> Result, .transpose() } +fn resolve_turn_environment_selections( + thread_manager: &ThreadManager, + environments: Option>, +) -> Result>, JSONRPCErrorError> { + let Some(environments) = environments else { + return Ok(None); + }; + let mut selections = Vec::with_capacity(environments.len()); + for environment in environments { + let environment_id = environment.environment_id; + let cwd = environment + .cwd + .infer_absolute_path_convention() + .and_then(|convention| environment.cwd.to_path_uri(convention).ok()) + .ok_or_else(|| { + invalid_request(format!( + "invalid cwd for environment `{environment_id}`: path `{}` does not use absolute POSIX or Windows path syntax", + environment.cwd + )) + })?; + selections.push(TurnEnvironmentSelection { + environment_id, + cwd, + }); + } + thread_manager + .validate_environment_selections(&selections) + .map_err(environment_selection_error)?; + Ok(Some(selections)) +} + 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 09ff0dd69..a5299f2c7 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -4,7 +4,6 @@ use codex_app_server_protocol::SelectedCapabilityRoot; use codex_extension_api::ExtensionDataInit; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_DANGER_FULL_ACCESS; use codex_protocol::models::BUILT_IN_PERMISSION_PROFILE_WORKSPACE; -use codex_utils_path_uri::PathUri; const THREAD_LIST_DEFAULT_LIMIT: usize = 25; const THREAD_LIST_MAX_LIMIT: usize = 100; @@ -906,7 +905,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)?; let runtime_workspace_roots = runtime_workspace_roots.map(resolve_runtime_workspace_roots); let mut typesafe_overrides = self.build_thread_config_overrides( model, @@ -1311,27 +1311,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 8d1810d87..d48e8c04b 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -4,7 +4,6 @@ use codex_protocol::protocol::AdditionalContextKind as CoreAdditionalContextKind use codex_protocol::protocol::MultiAgentVersion; use codex_protocol::protocol::SessionSource; use codex_protocol::protocol::SubAgentSource; -use codex_utils_path_uri::PathUri; const DIRECT_INPUT_TO_MULTI_AGENT_V2_SUBAGENT_ERROR: &str = "direct app-server input is not allowed for multi-agent v2 sub-agents"; @@ -341,27 +340,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 +411,8 @@ 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)?; // 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 82a2b6b2c..c0946ba67 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_start.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_start.rs @@ -293,7 +293,10 @@ 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: codex_utils_absolute_path::AbsolutePathBuf::try_from( + codex_home.path().to_path_buf(), + )? + .into(), }]), ..Default::default() }) @@ -312,6 +315,40 @@ async fn thread_start_rejects_unknown_environment_as_invalid_request() -> Result Ok(()) } +#[tokio::test] +async fn thread_start_rejects_relative_environment_cwd_as_invalid_request() -> 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 mut mcp = TestAppServer::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let request_id = mcp + .send_thread_start_request(ThreadStartParams { + environments: Some(vec![TurnEnvironmentParams { + environment_id: "local".to_string(), + cwd: serde_json::from_value(json!("relative"))?, + }]), + ..Default::default() + }) + .await?; + let error: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(request_id)), + ) + .await??; + + assert_eq!(error.id, RequestId::Integer(request_id)); + assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); + assert_eq!( + error.error.message, + "invalid cwd for environment `local`: path `relative` does not use absolute POSIX or Windows path syntax" + ); + + Ok(()) +} + #[tokio::test] async fn thread_start_response_includes_loaded_instruction_sources() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; 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 6567b06be..76b02a6d6 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_absolute_path::test_support::PathExt; use core_test_support::responses; use core_test_support::skip_if_no_network; use pretty_assertions::assert_eq; @@ -1326,7 +1327,10 @@ 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: codex_utils_absolute_path::AbsolutePathBuf::try_from( + codex_home.path().to_path_buf(), + )? + .into(), }]), ..Default::default() }) @@ -2669,7 +2673,7 @@ async fn run_environment_selection_case( .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), - environments: environment_params(case.sticky, workspace)?, + environments: environment_params(case.sticky, workspace), ..Default::default() }) .await?; @@ -2688,7 +2692,7 @@ async fn run_environment_selection_case( text: format!("run {}", case.name), text_elements: Vec::new(), }], - environments: environment_params(case.turn, workspace)?, + environments: environment_params(case.turn, workspace), cwd: Some(workspace.to_path_buf()), model: Some("mock-model".to_string()), ..Default::default() @@ -2735,21 +2739,15 @@ async fn run_environment_selection_case( Ok(()) } -fn environment_params( - ids: Option<&[&str]>, - cwd: &Path, -) -> Result>> { +fn environment_params(ids: Option<&[&str]>, cwd: &Path) -> Option> { ids.map(|ids| { ids.iter() - .map(|id| { - Ok(TurnEnvironmentParams { - environment_id: (*id).to_string(), - cwd: cwd.to_path_buf().try_into()?, - }) + .map(|id| TurnEnvironmentParams { + environment_id: (*id).to_string(), + cwd: cwd.abs().into(), }) .collect() }) - .transpose() } #[tokio::test] diff --git a/codex-rs/core/src/agents_md.rs b/codex-rs/core/src/agents_md.rs index 5cb70dc5f..861e7b4ac 100644 --- a/codex-rs/core/src/agents_md.rs +++ b/codex-rs/core/src/agents_md.rs @@ -53,11 +53,16 @@ pub(crate) async fn load_project_instructions( let mut loaded = LoadedAgentsMd::from_user_instructions(user_instructions); for turn_environment in &environments.turn_environments { let filesystem = turn_environment.environment.get_filesystem(); + // TODO(anp): Migrate AGENTS.md discovery to PathUri so instructions can be loaded from + // environment-native foreign working directories. + let Ok(cwd) = turn_environment.cwd().to_abs_path() else { + continue; + }; match read_agents_md( config, filesystem.as_ref(), &turn_environment.environment_id, - turn_environment.cwd(), + &cwd, ) .await { diff --git a/codex-rs/core/src/agents_md_tests.rs b/codex-rs/core/src/agents_md_tests.rs index ffe12d52c..42604629a 100644 --- a/codex-rs/core/src/agents_md_tests.rs +++ b/codex-rs/core/src/agents_md_tests.rs @@ -267,7 +267,7 @@ fn resolved_local_environments( Environment::create_for_tests(/*exec_server_url*/ None) .expect("local environment"), ), - cwd, + PathUri::from_abs_path(&cwd), /*shell*/ None, ) }) diff --git a/codex-rs/core/src/context/environment_context.rs b/codex-rs/core/src/context/environment_context.rs index 8aa07d1ed..f0cc9f80b 100644 --- a/codex-rs/core/src/context/environment_context.rs +++ b/codex-rs/core/src/context/environment_context.rs @@ -44,14 +44,18 @@ impl EnvironmentContextEnvironment { fn from_turn_environments(environments: &[TurnEnvironment], shell: &Shell) -> Vec { environments .iter() - .map(|environment| Self { - id: environment.environment_id.clone(), - cwd: environment.cwd().clone(), - shell: environment - .shell - .as_ref() - .map(|shell| shell.name().to_string()) - .unwrap_or_else(|| shell.name().to_string()), + .filter_map(|environment| { + // TODO(anp): Migrate EnvironmentContextEnvironment to PathUri so foreign + // environments remain visible in model context. + Some(Self { + id: environment.environment_id.clone(), + cwd: environment.cwd().to_abs_path().ok()?, + shell: environment + .shell + .as_ref() + .map(|shell| shell.name().to_string()) + .unwrap_or_else(|| shell.name().to_string()), + }) }) .collect() } diff --git a/codex-rs/core/src/environment_selection.rs b/codex-rs/core/src/environment_selection.rs index 8fb59ba92..16b2c47b5 100644 --- a/codex-rs/core/src/environment_selection.rs +++ b/codex-rs/core/src/environment_selection.rs @@ -97,7 +97,7 @@ impl ThreadEnvironments { } let turn_environment = match current.turn_environments.iter().find(|environment| { environment.environment_id == selected_environment.environment_id - && environment.cwd_uri() == &selected_environment.cwd + && environment.cwd() == &selected_environment.cwd }) { Some(environment) => environment.clone(), None => match Self::resolve_selection( @@ -157,12 +157,7 @@ impl ThreadEnvironments { let mut turn_environment = TurnEnvironment::new( environment_id, environment, - selected_environment.cwd.to_abs_path().map_err(|err| { - CodexErr::InvalidRequest(format!( - "turn environment cwd `{}` is not valid on this host: {err}", - selected_environment.cwd - )) - })?, + selected_environment.cwd.clone(), shell, ); let task = shell_snapshot @@ -226,8 +221,10 @@ impl TurnEnvironmentSnapshot { (!environment.environment.is_remote()).then_some(environment) } - pub(crate) fn single_local_environment_cwd(&self) -> Option<&AbsolutePathBuf> { - self.single_local_environment().map(TurnEnvironment::cwd) + pub(crate) fn single_local_environment_cwd(&self) -> Option { + // TODO(anp): Migrate local-environment consumers to PathUri so this compatibility + // conversion can be removed. + self.single_local_environment()?.cwd().to_abs_path().ok() } } @@ -560,7 +557,7 @@ url = "ws://127.0.0.1:8765" Arc::clone(&local_manager), &[TurnEnvironmentSelection { environment_id: LOCAL_ENVIRONMENT_ID.to_string(), - cwd: cwd_uri, + cwd: cwd_uri.clone(), }], ) .await; @@ -573,7 +570,7 @@ url = "ws://127.0.0.1:8765" turn_environments: vec![TurnEnvironment::new( REMOTE_ENVIRONMENT_ID.to_string(), remote_environment.clone(), - cwd.clone(), + cwd_uri.clone(), /*shell*/ None, )], }; @@ -583,13 +580,13 @@ url = "ws://127.0.0.1:8765" TurnEnvironment::new( REMOTE_ENVIRONMENT_ID.to_string(), remote_environment, - cwd.clone(), + cwd_uri, /*shell*/ None, ), ], }; - assert_eq!(local.single_local_environment_cwd(), Some(&cwd)); + assert_eq!(local.single_local_environment_cwd(), Some(cwd)); assert_eq!(remote.single_local_environment_cwd(), None); assert_eq!(multiple.single_local_environment_cwd(), None); } diff --git a/codex-rs/core/src/guardian/review_session.rs b/codex-rs/core/src/guardian/review_session.rs index 9d96a2d60..7aa007891 100644 --- a/codex-rs/core/src/guardian/review_session.rs +++ b/codex-rs/core/src/guardian/review_session.rs @@ -750,11 +750,13 @@ async fn run_review_on_session( .unwrap_or_default(); let guardian_permission_profile = PermissionProfile::read_only(); let parent_turn_environments = params.parent_turn.environments.to_selections(); + // TODO(anp): Migrate guardian review thread settings to a PathUri fallback cwd so foreign + // parent environments do not fall back to the host-native config cwd. let parent_turn_legacy_fallback_cwd = params .parent_turn .environments .primary() - .map(|environment| environment.cwd().clone()) + .and_then(|environment| environment.cwd().to_abs_path().ok()) .unwrap_or_else(|| params.parent_turn.config.cwd.clone()); let submit_result = run_before_review_deadline( diff --git a/codex-rs/core/src/mcp_openai_file.rs b/codex-rs/core/src/mcp_openai_file.rs index 7c1e4d7c8..4dee0da71 100644 --- a/codex-rs/core/src/mcp_openai_file.rs +++ b/codex-rs/core/src/mcp_openai_file.rs @@ -122,7 +122,13 @@ async fn build_uploaded_argument_value( "no primary turn environment is available".to_string(), )); }; - let resolved_path = turn_environment.cwd().join(file_path); + // TODO(anp): Resolve app tool file arguments using the selected environment's native path + // convention so uploads can read relative paths from foreign environments. + let native_environment_cwd = turn_environment + .cwd() + .to_abs_path() + .map_err(|error| contextualize_error(error.to_string()))?; + let resolved_path = native_environment_cwd.join(file_path); let path_uri = PathUri::from_abs_path(&resolved_path); let fs = turn_environment.environment.get_filesystem(); let metadata = fs @@ -178,6 +184,7 @@ mod tests { use crate::session::tests::make_session_and_context; use crate::session::turn_context::TurnEnvironment; use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use std::path::Path; use std::sync::Arc; @@ -194,7 +201,7 @@ mod tests { *primary = TurnEnvironment::new( primary.environment_id.clone(), Arc::clone(&primary.environment), - cwd, + PathUri::from_abs_path(&cwd), primary.shell.clone(), ); } diff --git a/codex-rs/core/src/session/mcp.rs b/codex-rs/core/src/session/mcp.rs index cc6e42f85..d1b898222 100644 --- a/codex-rs/core/src/session/mcp.rs +++ b/codex-rs/core/src/session/mcp.rs @@ -318,17 +318,18 @@ impl Session { ) .await; let environment_manager = self.services.turn_environments.environment_manager(); - let mcp_runtime_context = match turn_context.environments.primary() { - Some(turn_environment) => McpRuntimeContext::new( - Arc::clone(&environment_manager), - turn_environment.cwd().to_path_buf(), - ), - None => McpRuntimeContext::new( - environment_manager, + // TODO(anp): Migrate MCP runtime cwd plumbing to PathUri so foreign environment cwd + // values can be used without falling back to the legacy host cwd. + let cwd = turn_context + .environments + .primary() + .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) + .map(|cwd| cwd.to_path_buf()) + .unwrap_or_else(|| { #[allow(deprecated)] - turn_context.cwd.to_path_buf(), - ), - }; + turn_context.cwd.to_path_buf() + }); + let mcp_runtime_context = McpRuntimeContext::new(environment_manager, cwd); let mcp_startup_cancellation_token = { let mut guard = self.services.mcp_startup_cancellation_token.lock().await; guard.cancel(); diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 46f20eaf5..578152814 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -1119,9 +1119,12 @@ impl Session { }; let mcp_runtime_context = { let turn_environments = sess.services.turn_environments.snapshot().await; + // TODO(anp): Migrate MCP runtime cwd plumbing to PathUri so foreign environment + // cwd values can be used without falling back to the session host cwd. let cwd = turn_environments .primary() - .map(|turn_environment| turn_environment.cwd().to_path_buf()) + .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) + .map(|cwd| cwd.to_path_buf()) .unwrap_or_else(|| session_configuration.cwd().to_path_buf()); McpRuntimeContext::new( sess.services.turn_environments.environment_manager(), diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs index 5086fd63b..b4b786c90 100644 --- a/codex-rs/core/src/session/tests.rs +++ b/codex-rs/core/src/session/tests.rs @@ -5697,7 +5697,7 @@ async fn request_permissions_tool_resolves_relative_paths_against_selected_envir turn_context_mut.environments.turn_environments[0] = TurnEnvironment::new( "remote".to_string(), current_environment.environment, - environment_cwd.clone(), + PathUri::from_abs_path(&environment_cwd), current_environment.shell, ); @@ -6316,13 +6316,14 @@ async fn primary_environment_uses_first_turn_environment() { let first_environment = turn_context.environments.turn_environments[0].clone(); #[allow(deprecated)] let second_cwd = turn_context.cwd.join("second"); + let second_cwd_uri = codex_utils_path_uri::PathUri::from_abs_path(&second_cwd); turn_context .environments .turn_environments .push(TurnEnvironment::new( "second".to_string(), Arc::clone(&first_environment.environment), - second_cwd.clone(), + second_cwd_uri.clone(), /*shell*/ None, )); @@ -6342,12 +6343,12 @@ async fn primary_environment_uses_first_turn_environment() { .find(|environment| environment.environment_id == "second") .expect("second environment") .cwd(), - &second_cwd + &second_cwd_uri ); assert_eq!(turn_context.environments.turn_environments.len(), 2); assert_eq!( turn_context.environments.turn_environments[1].cwd(), - &second_cwd + &second_cwd_uri ); } diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs index 3c783eb48..a105aeffe 100644 --- a/codex-rs/core/src/session/turn.rs +++ b/codex-rs/core/src/session/turn.rs @@ -414,13 +414,16 @@ pub(crate) async fn run_turn( async fn turn_diff_display_roots(turn_context: &TurnContext) -> Vec<(String, PathBuf)> { let mut display_roots = Vec::new(); for turn_environment in &turn_context.environments.turn_environments { - let root = get_git_repo_root_with_fs( - turn_environment.environment.get_filesystem().as_ref(), - turn_environment.cwd(), - ) - .await - .unwrap_or_else(|| turn_environment.cwd().clone()) - .into_path_buf(); + // TODO(anp): Migrate git-root discovery and diff display roots to PathUri so foreign + // environment roots can participate without host-native conversion. + let Ok(cwd) = turn_environment.cwd().to_abs_path() else { + continue; + }; + let root = + get_git_repo_root_with_fs(turn_environment.environment.get_filesystem().as_ref(), &cwd) + .await + .unwrap_or(cwd) + .into_path_buf(); display_roots.push((turn_environment.environment_id.clone(), root)); } display_roots @@ -634,10 +637,14 @@ async fn build_extension_turn_input_items( .turn_environments .iter() .enumerate() - .map(|(index, environment)| TurnInputEnvironment { - environment_id: environment.environment_id.clone(), - cwd: environment.cwd().as_path().to_path_buf(), - is_primary: index == 0, + .filter_map(|(index, environment)| { + // TODO(anp): Migrate extension turn-input environments to PathUri so foreign cwd + // values are not omitted from extension context. + Some(TurnInputEnvironment { + environment_id: environment.environment_id.clone(), + cwd: environment.cwd().to_abs_path().ok()?.into_path_buf(), + is_primary: index == 0, + }) }) .collect::>(); diff --git a/codex-rs/core/src/session/turn_context.rs b/codex-rs/core/src/session/turn_context.rs index 042eca892..aed5fae68 100644 --- a/codex-rs/core/src/session/turn_context.rs +++ b/codex-rs/core/src/session/turn_context.rs @@ -3,7 +3,6 @@ use crate::SkillLoadOutcome; use crate::agents_md::LoadedAgentsMd; use crate::config::GhostSnapshotConfig; use crate::environment_selection::TurnEnvironmentSnapshot; -use crate::path_utils; use crate::shell_snapshot::ShellSnapshotFile; use codex_core_skills::HostLoadedSkills; use codex_file_system::FileSystemSandboxContext; @@ -49,13 +48,7 @@ pub(crate) type ShellSnapshotTask = Shared, - // Keep both representations together while cwd consumers migrate to URI semantics. Keeping - // them synchronized means neither representation can be exposed through a mutable reference; - // updates must rebuild the validated pair through `TurnEnvironment::new`. Once - // `TurnEnvironment::cwd` itself becomes a `PathUri`, convert only at native filesystem and - // process-launch boundaries and remove this paired migration state. - cwd: AbsolutePathBuf, - cwd_uri: PathUri, + cwd: PathUri, pub(crate) shell: Option, pub(crate) shell_snapshot: ShellSnapshotTask, } @@ -64,22 +57,20 @@ impl TurnEnvironment { pub(crate) fn new( environment_id: String, environment: Arc, - cwd: AbsolutePathBuf, + cwd: PathUri, shell: Option, ) -> Self { - let cwd_uri = PathUri::from_abs_path(&cwd); Self { environment_id, environment, cwd, - cwd_uri, shell, shell_snapshot: futures::future::ready(None).boxed().shared(), } } pub(crate) fn shell_snapshot(&self, cwd: &AbsolutePathBuf) -> Option { - if !path_utils::paths_match_after_normalization(self.cwd.as_path(), cwd.as_path()) { + if self.cwd != PathUri::from_abs_path(cwd) { return None; } self.shell_snapshot @@ -88,18 +79,14 @@ impl TurnEnvironment { .map(ShellSnapshotFile::path) } - pub(crate) fn cwd(&self) -> &AbsolutePathBuf { + pub(crate) fn cwd(&self) -> &PathUri { &self.cwd } - pub(crate) fn cwd_uri(&self) -> &PathUri { - &self.cwd_uri - } - pub(crate) fn selection(&self) -> TurnEnvironmentSelection { TurnEnvironmentSelection { environment_id: self.environment_id.clone(), - cwd: self.cwd_uri.clone(), + cwd: self.cwd.clone(), } } } @@ -110,7 +97,6 @@ impl std::fmt::Debug for TurnEnvironment { .field("environment_id", &self.environment_id) .field("environment", &self.environment) .field("cwd", &self.cwd) - .field("cwd_uri", &self.cwd_uri) .field("shell", &self.shell) .finish_non_exhaustive() } @@ -755,9 +741,11 @@ impl Session { ) -> Arc { let turn_environments = self.services.turn_environments.snapshot().await; let primary_turn_environment = turn_environments.primary().cloned(); + // TODO(anp): Migrate per-turn config and legacy TurnContext cwd consumers to PathUri so + // a foreign primary environment does not fall back to the session's host cwd. let cwd = primary_turn_environment .as_ref() - .map(|turn_environment| turn_environment.cwd().clone()) + .and_then(|turn_environment| turn_environment.cwd().to_abs_path().ok()) .unwrap_or_else(|| session_configuration.cwd().clone()); let per_turn_config = Self::build_per_turn_config(&session_configuration, cwd.clone()); { diff --git a/codex-rs/core/src/shell_snapshot.rs b/codex-rs/core/src/shell_snapshot.rs index 6e6c6cd51..6a9509300 100644 --- a/codex-rs/core/src/shell_snapshot.rs +++ b/codex-rs/core/src/shell_snapshot.rs @@ -76,7 +76,9 @@ impl ShellSnapshot { } let shell = environment.shell.clone()?; - let cwd = environment.cwd().clone(); + // TODO(anp): Migrate shell snapshot creation to accept PathUri and defer native + // conversion to the spawned shell process. + let cwd = environment.cwd().to_abs_path().ok()?; Self::build_for_cwd(Arc::clone(config), cwd, shell).await } diff --git a/codex-rs/core/src/tasks/user_shell.rs b/codex-rs/core/src/tasks/user_shell.rs index 6a01a87a0..4c3049351 100644 --- a/codex-rs/core/src/tasks/user_shell.rs +++ b/codex-rs/core/src/tasks/user_shell.rs @@ -144,7 +144,18 @@ pub(crate) async fn execute_user_shell_command( // We do not source rc files or otherwise reformat the script. let use_login_shell = true; let display_command = environment_shell.derive_exec_args(&command, use_login_shell); - let shell_snapshot_location = turn_environment.shell_snapshot(turn_environment.cwd()); + // TODO(anp): Migrate user-shell events and execution plumbing to PathUri so this local-only + // feature does not need to project the selected environment cwd onto the Codex host. + let Ok(cwd) = turn_environment.cwd().to_abs_path() else { + send_user_shell_error( + &session, + turn_context.as_ref(), + "shell working directory is not native to the Codex host", + ) + .await; + return; + }; + let shell_snapshot_location = turn_environment.shell_snapshot(&cwd); let mut exec_env_map = create_env( &turn_context.shell_environment_policy, Some(session.thread_id), @@ -162,7 +173,6 @@ pub(crate) async fn execute_user_shell_command( let call_id = Uuid::new_v4().to_string(); let raw_command = command; - let cwd = turn_environment.cwd().clone(); let parsed_cmd = parse_command(&display_command); session diff --git a/codex-rs/core/src/thread_manager_tests.rs b/codex-rs/core/src/thread_manager_tests.rs index ddfa5e701..cec54b5e4 100644 --- a/codex-rs/core/src/thread_manager_tests.rs +++ b/codex-rs/core/src/thread_manager_tests.rs @@ -606,11 +606,11 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { assert_eq!(resumed_turn.environments.turn_environments.len(), 1); assert_eq!( resumed_turn.environments.turn_environments[0].cwd(), - &default_cwd + &PathUri::from_abs_path(&default_cwd) ); assert_ne!( resumed_turn.environments.turn_environments[0].cwd(), - &selected_cwd + &PathUri::from_abs_path(&selected_cwd) ); let forked = manager @@ -633,11 +633,11 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() { assert_eq!(forked_turn.environments.turn_environments.len(), 1); assert_eq!( forked_turn.environments.turn_environments[0].cwd(), - &default_cwd + &PathUri::from_abs_path(&default_cwd) ); assert_ne!( forked_turn.environments.turn_environments[0].cwd(), - &selected_cwd + &PathUri::from_abs_path(&selected_cwd) ); } diff --git a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs index 255698962..a519e5db1 100644 --- a/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs +++ b/codex-rs/core/src/tools/handlers/agent_jobs/spawn_agents_on_csv.rs @@ -299,7 +299,7 @@ pub async fn handle( Ok(FunctionToolOutput::from_text(content, Some(true))) } -fn single_local_environment_cwd(turn: &TurnContext) -> Result<&AbsolutePathBuf, FunctionCallError> { +fn single_local_environment_cwd(turn: &TurnContext) -> Result { let [turn_environment] = turn.environments.turn_environments.as_slice() else { return Err(FunctionCallError::RespondToModel( "spawn_agents_on_csv requires exactly one local environment".to_string(), @@ -312,5 +312,12 @@ fn single_local_environment_cwd(turn: &TurnContext) -> Result<&AbsolutePathBuf, )); } - Ok(turn_environment.cwd()) + // TODO(anp): Migrate spawn_agents_on_csv filesystem access to PathUri before enabling it for + // remote environments. + turn_environment.cwd().to_abs_path().map_err(|err| { + FunctionCallError::RespondToModel(format!( + "spawn_agents_on_csv cwd `{}` is not native to the Codex host: {err}", + turn_environment.cwd() + )) + }) } diff --git a/codex-rs/core/src/tools/handlers/apply_patch.rs b/codex-rs/core/src/tools/handlers/apply_patch.rs index a719e9acd..2ce9119e3 100644 --- a/codex-rs/core/src/tools/handlers/apply_patch.rs +++ b/codex-rs/core/src/tools/handlers/apply_patch.rs @@ -359,11 +359,18 @@ impl ApplyPatchHandler { "apply_patch is unavailable in this session".to_string(), )); }; - let cwd = turn_environment.cwd().clone(); + // TODO(anp): Migrate apply-patch verification and permission accounting to PathUri so + // patches can target environment-native foreign paths without host projection. + let cwd = turn_environment.cwd().to_abs_path().map_err(|err| { + FunctionCallError::RespondToModel(format!( + "apply_patch cwd `{}` is not native to the Codex host: {err}", + turn_environment.cwd() + )) + })?; let fs = turn_environment.environment.get_filesystem(); let sandbox = turn.file_system_sandbox_context( /*additional_permissions*/ None, - turn_environment.cwd_uri(), + turn_environment.cwd(), ); match codex_apply_patch::verify_apply_patch_args(args, &cwd, fs.as_ref(), Some(&sandbox)) .await diff --git a/codex-rs/core/src/tools/handlers/extension_tools.rs b/codex-rs/core/src/tools/handlers/extension_tools.rs index f0522c7c1..b59bfaec0 100644 --- a/codex-rs/core/src/tools/handlers/extension_tools.rs +++ b/codex-rs/core/src/tools/handlers/extension_tools.rs @@ -114,10 +114,15 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { ConversationHistory::new(invocation.session.clone_history().await.into_raw_items()); let mut environments = Vec::with_capacity(invocation.turn.environments.turn_environments.len()); for environment in &invocation.turn.environments.turn_environments { + // TODO(anp): Migrate extension ToolEnvironment and granted-permission lookup to PathUri + // so extensions can receive foreign environment cwd values. + let Ok(native_cwd) = environment.cwd().to_abs_path() else { + continue; + }; let additional_permissions = apply_granted_turn_permissions( invocation.session.as_ref(), &environment.environment_id, - environment.cwd().as_path(), + native_cwd.as_path(), SandboxPermissions::UseDefault, /*additional_permissions*/ None, ) @@ -125,10 +130,10 @@ async fn to_extension_call(invocation: &ToolInvocation) -> ExtensionToolCall { .additional_permissions; let file_system_sandbox_context = invocation .turn - .file_system_sandbox_context(additional_permissions, environment.cwd_uri()); + .file_system_sandbox_context(additional_permissions, environment.cwd()); environments.push(ToolEnvironment { environment_id: environment.environment_id.clone(), - cwd: environment.cwd().clone(), + cwd: native_cwd, file_system: environment.environment.get_filesystem(), file_system_sandbox_context, }); @@ -315,7 +320,7 @@ mod tests { .environments .turn_environments .iter() - .map(|environment| Some(environment.cwd_uri().clone())) + .map(|environment| Some(environment.cwd().clone())) .collect::>(); let history_item = ResponseItem::Message { id: None, diff --git a/codex-rs/core/src/tools/handlers/request_permissions.rs b/codex-rs/core/src/tools/handlers/request_permissions.rs index 53341b14f..afc15f965 100644 --- a/codex-rs/core/src/tools/handlers/request_permissions.rs +++ b/codex-rs/core/src/tools/handlers/request_permissions.rs @@ -70,8 +70,16 @@ impl RequestPermissionsHandler { "request_permissions requires a primary environment".to_string(), )); }; + // TODO(anp): Migrate request_permissions parsing and permission profiles to PathUri so + // environment-native foreign paths do not require host conversion. + let native_cwd = turn_environment.cwd().to_abs_path().map_err(|err| { + FunctionCallError::RespondToModel(format!( + "request_permissions cwd `{}` is not native to the Codex host: {err}", + turn_environment.cwd() + )) + })?; let mut args: RequestPermissionsArgs = - parse_arguments_with_base_path(&arguments, turn_environment.cwd())?; + parse_arguments_with_base_path(&arguments, &native_cwd)?; args.permissions = normalize_additional_permissions(args.permissions.into()) .map(codex_protocol::request_permissions::RequestPermissionProfile::from) .map_err(FunctionCallError::RespondToModel)?; diff --git a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs index eb89e56ef..996ae9b52 100644 --- a/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs +++ b/codex-rs/core/src/tools/handlers/unified_exec/exec_command.rs @@ -129,13 +129,21 @@ impl ExecCommandHandler { "unified exec is unavailable in this session".to_string(), )); }; + // TODO(anp): Resolve tool paths using the selected environment's native path convention + // so unified exec can support relative paths in foreign environments. + let native_environment_cwd = turn_environment.cwd().to_abs_path().map_err(|err| { + FunctionCallError::RespondToModel(format!( + "environment cwd `{}` is not native to the Codex host: {err}", + turn_environment.cwd() + )) + })?; let cwd = environment_args .workdir .as_deref() .filter(|workdir| !workdir.is_empty()) .map_or_else( - || turn_environment.cwd().clone(), - |workdir| turn_environment.cwd().join(workdir), + || native_environment_cwd.clone(), + |workdir| native_environment_cwd.join(workdir), ); let environment = Arc::clone(&turn_environment.environment); let fs = environment.get_filesystem(); @@ -277,7 +285,7 @@ impl ExecCommandHandler { yield_time_ms, max_output_tokens, cwd, - sandbox_cwd: turn_environment.cwd().clone(), + sandbox_cwd: native_environment_cwd, turn_environment: turn_environment.clone(), shell_mode, network: context.turn.network.clone(), diff --git a/codex-rs/core/src/tools/handlers/view_image.rs b/codex-rs/core/src/tools/handlers/view_image.rs index dc52e7e33..45a1adc24 100644 --- a/codex-rs/core/src/tools/handlers/view_image.rs +++ b/codex-rs/core/src/tools/handlers/view_image.rs @@ -143,11 +143,18 @@ impl ViewImageHandler { "view_image is unavailable in this session".to_string(), )); }; - let cwd = turn_environment.cwd().clone(); + // TODO(anp): Resolve tool paths using the selected environment's native path convention + // so view_image can support relative paths in foreign environments. + let cwd = turn_environment.cwd().to_abs_path().map_err(|err| { + FunctionCallError::RespondToModel(format!( + "environment cwd `{}` is not native to the Codex host: {err}", + turn_environment.cwd() + )) + })?; let abs_path = cwd.join(path); let sandbox = turn.file_system_sandbox_context( /*additional_permissions*/ None, - turn_environment.cwd_uri(), + turn_environment.cwd(), ); let fs = turn_environment.environment.get_filesystem(); let path_uri = PathUri::from_abs_path(&abs_path); @@ -272,6 +279,7 @@ mod tests { use crate::turn_diff_tracker::TurnDiffTracker; use codex_protocol::models::PermissionProfile; use codex_utils_absolute_path::AbsolutePathBuf; + use codex_utils_path_uri::PathUri; use core_test_support::TempDirExt; use pretty_assertions::assert_eq; use serde_json::json; @@ -288,7 +296,7 @@ mod tests { turn.environments.turn_environments[0] = TurnEnvironment::new( current.environment_id, current.environment, - cwd, + PathUri::from_abs_path(&cwd), current.shell, ); } diff --git a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs index 4100b662e..51ca0b98c 100644 --- a/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs +++ b/codex-rs/core/src/tools/runtimes/apply_patch_tests.rs @@ -19,7 +19,7 @@ fn test_turn_environment(environment_id: &str) -> crate::session::turn_context:: crate::session::turn_context::TurnEnvironment::new( environment_id.to_string(), std::sync::Arc::new(codex_exec_server::Environment::default_for_tests()), - std::env::temp_dir().abs(), + PathUri::from_abs_path(&std::env::temp_dir().abs()), /*shell*/ None, ) } diff --git a/codex-rs/core/src/tools/runtimes/unified_exec.rs b/codex-rs/core/src/tools/runtimes/unified_exec.rs index 25a1b932c..eb23fc0ce 100644 --- a/codex-rs/core/src/tools/runtimes/unified_exec.rs +++ b/codex-rs/core/src/tools/runtimes/unified_exec.rs @@ -427,6 +427,7 @@ mod tests { use codex_exec_server::Environment; use codex_exec_server::LOCAL_ENVIRONMENT_ID; use codex_tools::ZshForkConfig; + use codex_utils_path_uri::PathUri; use std::sync::Arc; use std::time::Duration; use tempfile::tempdir; @@ -435,7 +436,7 @@ mod tests { TurnEnvironment::new( LOCAL_ENVIRONMENT_ID.to_string(), Arc::new(Environment::default_for_tests()), - cwd, + PathUri::from_abs_path(&cwd), /*shell*/ None, ) } diff --git a/codex-rs/core/tests/remote_env_windows/BUILD.bazel b/codex-rs/core/tests/remote_env_windows/BUILD.bazel index 9b5ce52bd..5e213ac14 100644 --- a/codex-rs/core/tests/remote_env_windows/BUILD.bazel +++ b/codex-rs/core/tests/remote_env_windows/BUILD.bazel @@ -22,6 +22,7 @@ wine_rust_test( "//codex-rs/protocol", "//codex-rs/utils/path-uri", "@crates//:anyhow", + "@crates//:base64", "@crates//:pretty_assertions", "@crates//:serde_json", "@crates//:tempfile", diff --git a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs index 24efb8e7e..208dfa554 100644 --- a/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs +++ b/codex-rs/core/tests/remote_env_windows/remote_env_windows_test.rs @@ -2,9 +2,18 @@ use anyhow::Context; use anyhow::Result; +use app_test_support::PathBufExt; use app_test_support::TestAppServer; -use codex_app_server_protocol::JSONRPCError; +use app_test_support::create_mock_responses_server_repeating_assistant; +use app_test_support::to_response; +use app_test_support::write_mock_responses_config_toml; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::ThreadStartParams; +use codex_app_server_protocol::ThreadStartResponse; +use codex_app_server_protocol::TurnEnvironmentParams; +use codex_app_server_protocol::TurnStartParams; +use codex_app_server_protocol::TurnStartResponse; +use codex_app_server_protocol::UserInput as V2UserInput; use codex_exec_server::REMOTE_ENVIRONMENT_ID; use codex_exec_server::CODEX_EXEC_SERVER_URL_ENV_VAR; use codex_features::Feature; @@ -26,20 +35,22 @@ use core_test_support::responses::start_mock_server; use core_test_support::test_codex::test_codex; use core_test_support::test_codex::turn_permission_fields; use core_test_support::wait_for_event; +use codex_utils_path_uri::LegacyAppPathString; use codex_utils_path_uri::PathUri; use pretty_assertions::assert_eq; use serde_json::json; +use std::collections::BTreeMap; use tempfile::TempDir; use tokio::time::timeout; use wine_exec_server_test_support::WineExecServer; const APP_SERVER_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); -const CALL_ID: &str = "wine-cmd-smoke"; -const COMMAND: &str = r#"if ((Get-Location).Path -ne 'C:\windows') { exit 1 }"#; -const INVALID_REQUEST_ERROR_CODE: i64 = -32600; #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { + const CALL_ID: &str = "wine-cmd-smoke"; + const COMMAND: &str = r#"if ((Get-Location).Path -ne 'C:\windows') { exit 1 }"#; + WineExecServer .scope(|exec_server_url| async move { let server = start_mock_server().await; @@ -162,10 +173,20 @@ async fn windows_exec_server_runs_with_native_shell_and_cwd() -> Result<()> { } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] -async fn app_server_rejects_windows_environment_cwd() -> Result<()> { +async fn app_server_starts_thread_with_windows_environment_native_cwd() -> Result<()> { WineExecServer .scope(|exec_server_url| async move { let codex_home = TempDir::new()?; + let server = create_mock_responses_server_repeating_assistant("done").await; + write_mock_responses_config_toml( + codex_home.path(), + &server.uri(), + &BTreeMap::new(), + 100_000, + /*requires_openai_auth*/ None, + "mock", + "compact", + )?; let mut app_server = TestAppServer::new_with_env( codex_home.path(), &[( @@ -177,28 +198,53 @@ async fn app_server_rejects_windows_environment_cwd() -> Result<()> { timeout(APP_SERVER_READ_TIMEOUT, app_server.initialize()).await??; let request_id = app_server - .send_raw_request( - "thread/start", - Some(json!({ - "environments": [{ - "environmentId": REMOTE_ENVIRONMENT_ID, - "cwd": r"C:\windows", - }], - })), - ) + .send_thread_start_request(ThreadStartParams { + environments: Some(vec![TurnEnvironmentParams { + environment_id: REMOTE_ENVIRONMENT_ID.to_string(), + cwd: serde_json::from_value::(json!(r"C:\windows"))?, + }]), + ..Default::default() + }) .await?; - let error: JSONRPCError = timeout( + let response = timeout( APP_SERVER_READ_TIMEOUT, - app_server.read_stream_until_error_message(RequestId::Integer(request_id)), + app_server.read_stream_until_response_message(RequestId::Integer(request_id)), ) .await??; + let response: ThreadStartResponse = to_response(response)?; + assert!(!response.thread.id.is_empty()); + let host_cwd = codex_home.path().to_path_buf().abs(); + // TODO(anp): Return the selected environment's native cwd from thread/start. + assert_eq!(response.cwd, host_cwd); + // TODO(anp): Derive runtime workspace roots from the selected remote environment. + assert_eq!(response.runtime_workspace_roots, vec![host_cwd]); + // TODO(anp): Discover and report instruction sources from the remote filesystem. + assert_eq!(response.instruction_sources, Vec::new()); + // TODO(anp): Report the implicit built-in permission profile instead of None. + assert_eq!(response.active_permission_profile, None); - assert_eq!(error.id, RequestId::Integer(request_id)); - assert_eq!(error.error.code, INVALID_REQUEST_ERROR_CODE); - assert_eq!( - error.error.message, - "Invalid request: AbsolutePathBuf deserialized without a base path" - ); + let turn_request_id = app_server + .send_turn_start_request(TurnStartParams { + thread_id: response.thread.id, + client_user_message_id: None, + input: vec![V2UserInput::Text { + text: "say done".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + let turn_response = timeout( + APP_SERVER_READ_TIMEOUT, + app_server.read_stream_until_response_message(RequestId::Integer(turn_request_id)), + ) + .await??; + let _: TurnStartResponse = to_response(turn_response)?; + timeout( + APP_SERVER_READ_TIMEOUT, + app_server.read_stream_until_notification_message("turn/completed"), + ) + .await??; Ok(()) })