diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 5abf8ef5e0..aa68d67eb6 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -4139,6 +4139,32 @@ dependencies = [ "tracing", ] +[[package]] +name = "codex-multi-env-copy-file" +version = "0.0.0" +dependencies = [ + "anyhow", + "codex-config", + "codex-core", + "codex-exec-server", + "codex-extension-api", + "codex-features", + "codex-models-manager", + "codex-protocol", + "codex-tools", + "codex-utils-absolute-path", + "codex-utils-path-uri", + "core_test_support", + "futures", + "insta", + "pretty_assertions", + "serde", + "serde_json", + "tempfile", + "test-case", + "tokio", +] + [[package]] name = "codex-mxc-sandbox" version = "0.0.0" @@ -9334,6 +9360,8 @@ checksum = "e82db8c87c7f1ccecb34ce0c24399b8a73081427f3c7c50a5d597925356115e4" dependencies = [ "console", "once_cell", + "regex", + "serde", "similar", "tempfile", ] diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 978b90f36e..ee3d8c493a 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -63,6 +63,7 @@ members = [ "ext/agent-message-board", "ext/connectors", "ext/extension-api", + "ext/multi-env-copy-file", "ext/goal", "ext/git-attribution", "ext/guardian-reviewer", diff --git a/codex-rs/ext/multi-env-copy-file/BUILD.bazel b/codex-rs/ext/multi-env-copy-file/BUILD.bazel new file mode 100644 index 0000000000..6f2fc4e1a9 --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/BUILD.bazel @@ -0,0 +1,17 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "multi-env-copy-file", + crate_name = "codex_multi_env_copy_file", + extra_binaries = [ + "//codex-rs/bwrap:bwrap", + "//codex-rs/code-mode-host:codex-code-mode-host", + "//codex-rs/linux-sandbox:codex-linux-sandbox", + "//codex-rs/cli:codex", + "//codex-rs/windows-sandbox-rs:codex-command-runner", + "//codex-rs/windows-sandbox-rs:codex-windows-managed-deny-probe", + "//codex-rs/windows-sandbox-rs:codex-windows-sandbox-setup", + ], + run_tests_with_wine_exec = True, + test_tags = ["no-sandbox"], +) diff --git a/codex-rs/ext/multi-env-copy-file/Cargo.toml b/codex-rs/ext/multi-env-copy-file/Cargo.toml new file mode 100644 index 0000000000..3c64e174a4 --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/Cargo.toml @@ -0,0 +1,36 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-multi-env-copy-file" +version.workspace = true + +[lib] +test = false +doctest = false + +[lints] +workspace = true + +[dependencies] +codex-extension-api = { workspace = true } +codex-tools = { workspace = true } +codex-utils-path-uri = { workspace = true } +futures = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } + +[dev-dependencies] +anyhow = { workspace = true } +codex-config = { workspace = true } +codex-core = { workspace = true } +codex-exec-server = { workspace = true } +codex-features = { workspace = true } +codex-models-manager = { workspace = true } +codex-protocol = { workspace = true } +codex-utils-absolute-path = { workspace = true } +core_test_support = { workspace = true } +insta = { workspace = true, features = ["json", "filters"] } +pretty_assertions = { workspace = true } +tempfile = { workspace = true } +test-case = "3.3.1" +tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } diff --git a/codex-rs/ext/multi-env-copy-file/src/extension.rs b/codex-rs/ext/multi-env-copy-file/src/extension.rs new file mode 100644 index 0000000000..7e55c0414e --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/src/extension.rs @@ -0,0 +1,28 @@ +//! Exposes file copying independently of which environments a turn selects. + +use std::sync::Arc; + +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::ToolCall; +use codex_extension_api::ToolContributor; +use codex_extension_api::ToolExecutor; + +use crate::tool::CopyFileTool; + +struct FileCopyExtension; + +impl ToolContributor for FileCopyExtension { + fn tools( + &self, + _session_store: &ExtensionData, + _thread_store: &ExtensionData, + ) -> Vec ToolExecutor>>> { + vec![Arc::new(CopyFileTool)] + } +} + +/// Installs file copying after the host checks its feature flag. +pub fn install(registry: &mut ExtensionRegistryBuilder) { + registry.tool_contributor(Arc::new(FileCopyExtension)); +} diff --git a/codex-rs/ext/multi-env-copy-file/src/lib.rs b/codex-rs/ext/multi-env-copy-file/src/lib.rs new file mode 100644 index 0000000000..285221cc69 --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/src/lib.rs @@ -0,0 +1,4 @@ +mod extension; +mod tool; + +pub use extension::install; diff --git a/codex-rs/ext/multi-env-copy-file/src/tool.rs b/codex-rs/ext/multi-env-copy-file/src/tool.rs new file mode 100644 index 0000000000..a7cd0e2213 --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/src/tool.rs @@ -0,0 +1,149 @@ +//! Copies one file between selected environments using each endpoint's captured permissions. +//! Paths are absolute and bytes stay outside model context; failed copies may leave a partial target. + +use std::collections::BTreeMap; + +use codex_tools::FunctionCallError; +use codex_tools::JsonSchema; +use codex_tools::JsonToolOutput; +use codex_tools::ResponsesApiTool; +use codex_tools::ToolCall; +use codex_tools::ToolExecutor; +use codex_tools::ToolName; +use codex_tools::ToolSpec; +use codex_utils_path_uri::LegacyAppPathString; +use futures::StreamExt; +use serde::Deserialize; +use serde_json::json; + +const TOOL_NAME: &str = "multi_env_copy_file"; +const TOOL_DESCRIPTION: &str = "Copy a file between two different environments without loading it into context. Returns bytes copied after completion. Interrupted copies may leave files partially written."; + +pub(crate) struct CopyFileTool; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CopyFileArgs { + source_environment_id: String, + source_path: String, + target_environment_id: String, + target_path: String, +} + +impl<'call> ToolExecutor> for CopyFileTool { + fn tool_name(&self) -> ToolName { + ToolName::plain(TOOL_NAME) + } + + fn spec(&self) -> ToolSpec { + let fields = [ + ( + "source_environment_id", + "Source environment ID from .", + ), + ( + "source_path", + "Absolute file path in the source environment. Parent directories must exist.", + ), + ( + "target_environment_id", + "Target environment ID from .", + ), + ( + "target_path", + "Absolute file path in the target environment. Parent directories must exist.", + ), + ]; + let properties = fields + .iter() + .map(|(name, description)| { + ( + name.to_string(), + JsonSchema::string(Some(description.to_string())), + ) + }) + .collect::>(); + ToolSpec::Function(ResponsesApiTool { + name: TOOL_NAME.to_string(), + description: TOOL_DESCRIPTION.to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object( + properties, + Some(fields.iter().map(|(name, _)| name.to_string()).collect()), + Some(false.into()), + ), + output_schema: None, + }) + } + + fn handle<'a>(&'a self, call: ToolCall<'call>) -> codex_tools::ToolExecutorFuture<'a> + where + 'call: 'a, + { + Box::pin(async move { + let args: CopyFileArgs = serde_json::from_str(call.function_arguments()?) + .map_err(|error| FunctionCallError::RespondToModel(error.to_string()))?; + // Leave copies within one environment to tools like `cp`. They already handle + // different paths to the same file, including through hard links and symlinks. + if args.source_environment_id == args.target_environment_id { + return Err(FunctionCallError::RespondToModel( + "source and target environment IDs must differ. To copy within one environment, use a shell command.".to_string(), + )); + } + let resolve_endpoint = |id: &str, path: &str| { + let environment = call + .environments + .iter() + .find(|environment| environment.environment_id == id) + .ok_or_else(|| { + FunctionCallError::RespondToModel(format!( + "unknown or unavailable environment ID: {id}" + )) + })?; + let convention = environment.cwd.infer_path_convention().ok_or_else(|| { + FunctionCallError::RespondToModel(format!( + "invalid working directory path for environment {id}" + )) + })?; + let path = LegacyAppPathString::from_string(path) + .to_path_uri(convention) + .map_err(|_| { + FunctionCallError::RespondToModel(format!( + "provide an absolute {convention} file path for environment {id}" + )) + })?; + // The host binds this accessor to the endpoint's sandbox policy and current grants. + Ok::<_, FunctionCallError>((environment.fs(), path)) + }; + let (source, source_path) = + resolve_endpoint(&args.source_environment_id, &args.source_path)?; + let (target, target_path) = + resolve_endpoint(&args.target_environment_id, &args.target_path)?; + // The target writer pulls one block at a time when it supports streaming writes. + // For an older executor it buffers this stream and sends a single write instead. + // Report only the I/O error kind to the model. Executor connection errors can + // include credentials from a signed websocket URL in their message. + let contents = source + .read_file_stream(&source_path) + .await + .map_err(|error| { + FunctionCallError::RespondToModel(format!( + "could not open copy source: {}", + error.kind() + )) + })?; + let bytes_copied = target + .write_file_stream(&target_path, contents.boxed()) + .await + .map_err(|error| { + FunctionCallError::RespondToModel(format!( + "copy failed; target may be truncated or partially written: {}", + error.kind() + )) + })?; + let output = json!({"bytes_copied": bytes_copied}); + Ok(Box::new(JsonToolOutput::new(output)) as Box) + }) + } +} diff --git a/codex-rs/ext/multi-env-copy-file/tests/copy_file.rs b/codex-rs/ext/multi-env-copy-file/tests/copy_file.rs new file mode 100644 index 0000000000..fca1c365cd --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/tests/copy_file.rs @@ -0,0 +1,5 @@ +//! Integration tests for the multi-environment file-copy tool. + +#![allow(clippy::expect_used)] + +mod suite; diff --git a/codex-rs/ext/multi-env-copy-file/tests/suite/connectivity.rs b/codex-rs/ext/multi-env-copy-file/tests/suite/connectivity.rs new file mode 100644 index 0000000000..9e1f129cc7 --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/tests/suite/connectivity.rs @@ -0,0 +1,248 @@ +//! Keeps the tool stable across unavailable attachments and reports lost executor connections. + +use super::*; +use pretty_assertions::assert_eq; + +/// The tool stays visible and reports the unavailable endpoint when called. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn copy_file_remains_available_with_unavailable_attachment() -> Result<()> { + use codex_protocol::protocol::TurnEnvironmentSelection; + + let server = start_mock_server().await; + let mut extensions = ExtensionRegistryBuilder::new(); + codex_multi_env_copy_file::install(&mut extensions); + let test = test_codex() + .with_extensions(Arc::new(extensions.build())) + .build_with_auto_env(&server) + .await?; + let manager = test.thread_manager.environment_manager(); + manager.upsert_environment( + "unavailable".to_string(), + "ws://127.0.0.1:0".to_string(), + /*connect_timeout*/ None, + )?; + assert!( + manager + .get_environment("unavailable") + .context("attached environment")? + .wait_until_ready() + .await + .is_err() + ); + let primary = test.executor_environment().selection().clone(); + let mut calls = Vec::new(); + for (id, source, target) in [ + ( + "source-offline", + "unavailable", + primary.environment_id.as_str(), + ), + ( + "target-offline", + primary.environment_id.as_str(), + "unavailable", + ), + ] { + calls.push(ev_function_call_with_namespace( + id, + "functions", + "multi_env_copy_file", + &json!({ + "source_environment_id": source, + "source_path": primary.cwd.join("source.bin")?.inferred_native_path_string(), + "target_environment_id": target, + "target_path": primary.cwd.join("target.bin")?.inferred_native_path_string(), + }) + .to_string(), + )); + } + calls.push(ev_completed("unavailable-copies")); + let mock = mount_sse_sequence( + &server, + vec![ + sse(calls), + sse(vec![ + ev_assistant_message("done", "done"), + ev_completed("done"), + ]), + ], + ) + .await; + test.submit_turn_with_environments( + "Try copying to and from the unavailable environment.", + Some(vec![ + primary.clone(), + TurnEnvironmentSelection { + selected_capability_roots: Default::default(), + environment_id: "unavailable".to_string(), + cwd: primary.cwd.clone(), + workspace_roots: vec![primary.cwd], + config: EnvironmentConfigState::FromThread, + }, + ]), + ) + .await?; + let requests = mock.requests(); + let request = &requests[0]; + assert!( + request.body_json()["tools"] + .as_array() + .context("tools")? + .iter() + .any(|tool| tool["name"] == "multi_env_copy_file") + || request + .tool_by_name("functions", "multi_env_copy_file") + .is_some() + ); + insta::assert_json_snapshot!( + "unavailable_endpoints", + json!({ + "source": mock.function_call_output_text("source-offline").context("source error")?, + "target": mock.function_call_output_text("target-offline").context("target error")?, + }) + ); + Ok(()) +} + +/// Losing a captured executor must return a model-visible transport error without copying bytes. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn copy_file_reports_disconnection_after_step_capture() -> Result<()> { + use codex_exec_server::EnvironmentConnectionState; + use codex_protocol::protocol::ThreadSettingsOverrides; + use codex_protocol::protocol::TurnEnvironmentSelection; + use codex_protocol::protocol::TurnEnvironmentSelections; + use core_test_support::exec_server::ExecServerProcess; + use core_test_support::streaming_sse::StreamingSseChunk; + use core_test_support::streaming_sse::start_streaming_sse_server; + + let executor = ExecServerProcess::start().await?; + let available = ExecServerProcess::start().await?; + let root = TempDir::new()?; + let cwd = PathUri::from_host_native_path(root.path())?; + fs::write(root.path().join("source.bin"), b"source")?; + fs::write(root.path().join("target.bin"), b"original")?; + let mut calls = Vec::new(); + for (id, source, target) in [ + ("source-disconnected", "disconnected", "available"), + ("target-disconnected", "available", "disconnected"), + ] { + calls.push(ev_function_call_with_namespace( + id, + "functions", + "multi_env_copy_file", + &json!({ + "source_environment_id": source, + "source_path": cwd.join("source.bin")?.inferred_native_path_string(), + "target_environment_id": target, + "target_path": cwd.join("target.bin")?.inferred_native_path_string(), + }) + .to_string(), + )); + } + calls.push(ev_completed("copies")); + let (release_response, response_gate) = tokio::sync::oneshot::channel(); + let (streaming, _completions) = start_streaming_sse_server(vec![ + vec![StreamingSseChunk { + gate: Some(response_gate), + body: sse(calls), + }], + vec![StreamingSseChunk { + gate: None, + body: sse(vec![ + ev_assistant_message("done", "done"), + ev_completed("done"), + ]), + }], + ]) + .await; + let server = start_mock_server().await; + let mut extensions = ExtensionRegistryBuilder::new(); + codex_multi_env_copy_file::install(&mut extensions); + let base_url = format!("{}/v1", streaming.uri()); + let test = test_codex() + .with_extensions(Arc::new(extensions.build())) + .with_config(move |config| { + config.model_provider.base_url = Some(base_url); + // The gated server captures JSON request bodies for the error snapshot. + config + .features + .disable(Feature::EnableRequestCompression) + .unwrap(); + }) + .build_with_auto_env(&server) + .await?; + let manager = test.thread_manager.environment_manager(); + let mut selections = vec![test.executor_environment().selection().clone()]; + for (id, process) in [("available", &available), ("disconnected", &executor)] { + manager.upsert_environment( + id.to_string(), + format!("{}/?sig=copy-test-secret", process.websocket_url), + /*connect_timeout*/ None, + )?; + manager + .get_environment(id) + .context("remote environment")? + .wait_until_ready() + .await?; + selections.push(TurnEnvironmentSelection { + selected_capability_roots: Default::default(), + environment_id: id.to_string(), + cwd: cwd.clone(), + workspace_roots: vec![cwd.clone()], + config: EnvironmentConfigState::FromThread, + }); + } + let environment = manager + .get_environment("disconnected") + .context("remote environment")?; + let mut state = environment + .subscribe_connection_state() + .context("connection state")?; + test.codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "Copy files to and from the disconnected executor.".to_string(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + environments: Some( + TurnEnvironmentSelections::new(test.config.cwd.clone(), selections) + .into_requests(), + ), + ..Default::default() + }), + ) + .await?; + // The model request proves the step captured a connected executor. Drop it before the call. + streaming.wait_for_request_count(/*count*/ 1).await; + drop(executor); + state + .wait_for(|state| *state == EnvironmentConnectionState::Disconnected) + .await?; + release_response.send(()).unwrap(); + // Allow the client's recovery window to finish before checking the completed tool results. + streaming.wait_for_request_count(/*count*/ 2).await; + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + let requests = streaming.requests().await; + let request = std::str::from_utf8(&requests[1])?; + assert!( + !request.contains("copy-test-secret"), + "executor URL credential reached model context" + ); + let body: serde_json::Value = serde_json::from_str(request)?; + let outputs = body["input"] + .as_array() + .context("request input")? + .iter() + .filter(|item| item["type"] == "function_call_output") + .map(|item| json!({"call_id": item["call_id"], "output": item["output"]})) + .collect::>(); + assert_eq!(outputs.len(), 2); + insta::assert_json_snapshot!("disconnected_executor", outputs); + assert_eq!(fs::read(root.path().join("target.bin"))?, b"original"); + streaming.shutdown().await; + Ok(()) +} diff --git a/codex-rs/ext/multi-env-copy-file/tests/suite/mod.rs b/codex-rs/ext/multi-env-copy-file/tests/suite/mod.rs new file mode 100644 index 0000000000..ec3df6f759 --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/tests/suite/mod.rs @@ -0,0 +1,507 @@ +//! Covers opt-in file copying, argument validation, and independent endpoint permissions. + +use anyhow::Context; +use anyhow::Result; +use codex_core::TurnInputRequest; +use codex_exec_server::ReadFileOptions; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_features::Feature; +use codex_protocol::protocol::EnvironmentConfigState; +use codex_protocol::protocol::EventMsg; +use codex_protocol::protocol::Op; +use codex_protocol::user_input::UserInput; +use codex_utils_path_uri::PathConvention; +use codex_utils_path_uri::PathUri; +use core_test_support::responses::ev_assistant_message; +use core_test_support::responses::ev_completed; +use core_test_support::responses::ev_function_call_with_namespace; +use core_test_support::responses::mount_sse_sequence; +use core_test_support::responses::sse; +use core_test_support::responses::start_mock_server; +use core_test_support::test_codex::environment_config_for_selection; +use core_test_support::test_codex::test_codex; +use core_test_support::wait_for_event; +use pretty_assertions::assert_eq; +use serde_json::json; +use std::fs; +use std::sync::Arc; +use tempfile::TempDir; +use test_case::test_case; + +mod connectivity; +mod transfers; + +/// Host registration controls visibility, even before a turn selects any environments. +#[test_case(false, true; "disabled_by_default")] +#[test_case(true, false; "no_environments")] +#[test_case(true, true; "one_environment")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn copy_file_is_opt_in(enabled: bool, attach_environment: bool) -> Result<()> { + let server = start_mock_server().await; + let mut extensions = ExtensionRegistryBuilder::new(); + if enabled { + codex_multi_env_copy_file::install(&mut extensions); + } + let test = test_codex() + .with_extensions(Arc::new(extensions.build())) + .build_with_auto_env(&server) + .await?; + let selections = attach_environment + .then(|| test.executor_environment().selection().clone()) + .into_iter() + .collect(); + let mock = core_test_support::responses::mount_sse_once( + &server, + sse(vec![ + ev_assistant_message("done", "done"), + ev_completed("done-response"), + ]), + ) + .await; + test.submit_turn_with_environments("List available tools.", Some(selections)) + .await?; + let request = mock.single_request(); + let body = request.body_json(); + assert_eq!( + body["tools"] + .as_array() + .context("tools")? + .iter() + .any(|tool| tool["name"] == "multi_env_copy_file") + || request + .tool_by_name("functions", "multi_env_copy_file") + .is_some(), + enabled + ); + Ok(()) +} + +/// Invalid endpoints fail before truncating a target; directories and missing parents are rejected. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn copy_file_validates_endpoints() -> Result<()> { + use codex_protocol::protocol::TurnEnvironmentSelection; + use core_test_support::exec_server::ExecServerProcess; + let executor = ExecServerProcess::start().await?; + let root = TempDir::new()?; + let server = start_mock_server().await; + let mut extensions = ExtensionRegistryBuilder::new(); + codex_multi_env_copy_file::install(&mut extensions); + let test = test_codex() + .with_extensions(Arc::new(extensions.build())) + .build_with_auto_env(&server) + .await?; + let primary_file = test.workspace_path_uri("primary.txt")?; + test.fs() + .write_file( + &primary_file, + b"primary".to_vec(), + Default::default(), + /*sandbox*/ None, + ) + .await?; + let second_file = root.path().join("second.txt"); + fs::write(&second_file, b"second")?; + let selection = test.executor_environment().selection(); + let primary_path = primary_file.inferred_native_path_string(); + let primary = (selection.environment_id.as_str(), primary_path.as_str()); + let second = ("second", second_file.to_str().context("second file path")?); + let convention = selection + .cwd + .infer_path_convention() + .context("cwd path convention")?; + let foreign_path = match convention { + PathConvention::Posix => r"Z:\foreign\source.txt", + PathConvention::Windows => "/foreign/source.txt", + }; + let cases = [ + ("source_environment_id", None, "missing field"), + ("target_environment_id", None, "missing field"), + ( + "source_environment_id", + Some("unknown".to_string()), + "unknown", + ), + ( + "target_environment_id", + Some("unknown".to_string()), + "unknown", + ), + ("source_path", Some("source.txt".to_string()), "absolute"), + ("target_path", Some("target.txt".to_string()), "absolute"), + ("source_path", Some(foreign_path.to_string()), "absolute"), + ( + "source_path", + Some( + test.workspace_path_uri("missing.bin")? + .inferred_native_path_string(), + ), + "could not open copy source", + ), + ( + "target_path", + Some(selection.cwd.inferred_native_path_string()), + "copy failed", + ), + ( + "source_path", + Some(selection.cwd.inferred_native_path_string()), + "could not open copy source", + ), + ( + "target_path", + Some( + test.workspace_path_uri("missing/target.txt")? + .inferred_native_path_string(), + ), + "target may be truncated or partially written", + ), + ]; + let mut calls = Vec::new(); + for (index, (field, value, _)) in cases.iter().enumerate() { + // Exercise the selected executor's path conventions for both source and target errors. + let ((source_id, source), (target_id, target)) = if field.starts_with("source_") { + (primary, second) + } else { + (second, primary) + }; + let mut args = json!({ + "source_environment_id": source_id, + "source_path": source, + "target_environment_id": target_id, + "target_path": target, + }); + if let Some(value) = value { + args[field] = json!(value); + } else { + args.as_object_mut().unwrap().remove(*field); + } + calls.push(ev_function_call_with_namespace( + &format!("invalid-{index}"), + "functions", + "multi_env_copy_file", + &args.to_string(), + )); + } + calls.push(ev_completed("invalid-copies")); + let mock = mount_sse_sequence( + &server, + vec![ + sse(calls), + sse(vec![ + ev_assistant_message("done", "done"), + ev_completed("done"), + ]), + ], + ) + .await; + let manager = test.thread_manager.environment_manager(); + manager.upsert_environment( + "second".to_string(), + executor.websocket_url.clone(), + /*connect_timeout*/ None, + )?; + manager + .get_environment("second") + .context("second environment")? + .wait_until_ready() + .await?; + let cwd = PathUri::from_host_native_path(root.path())?; + test.submit_turn_with_environments( + "Try copying with these invalid endpoints.", + Some(vec![ + selection.clone(), + TurnEnvironmentSelection { + selected_capability_roots: Default::default(), + environment_id: "second".to_string(), + cwd: cwd.clone(), + workspace_roots: vec![cwd], + config: EnvironmentConfigState::FromThread, + }, + ]), + ) + .await?; + let mut errors = Vec::new(); + for (index, (field, value, expected)) in cases.iter().enumerate() { + let output = mock + .function_call_output_text(&format!("invalid-{index}")) + .context("copy error")?; + assert!(output.contains(expected), "{index}: {output}"); + let normalize = |text: &str| { + text.replace(&selection.cwd.to_string(), "") + .replace(&selection.cwd.inferred_native_path_string(), "") + .replace( + &format!("for environment {}", selection.environment_id), + "for environment ", + ) + }; + errors.push(json!({ + "field": field, + "value": value.as_deref().map(normalize), + "output": normalize(&output), + })); + } + // JSON error offsets depend on the workspace path's length. + let mut filters = vec![(r"at line 1 column \d+", "at line 1 column ")]; + let platform = match convention { + PathConvention::Posix => "unix", + PathConvention::Windows => { + // Opening a directory as a file reports PermissionDenied on native Windows + // and InvalidInput through the Wine executor. + filters.push(( + r": (?:permission denied|invalid input parameter)", + ": ", + )); + "windows" + } + }; + insta::with_settings!({snapshot_suffix => platform, sort_maps => true, filters => filters}, { + insta::assert_json_snapshot!("endpoint_errors", errors); + }); + assert_eq!( + test.fs() + .read_file( + &primary_file, + ReadFileOptions::default(), + /*sandbox*/ None + ) + .await?, + b"primary" + ); + assert_eq!(fs::read(second_file)?, b"second"); + Ok(()) +} + +/// Each endpoint uses its own executor and permissions, even for allowed paths outside its cwd. +#[test_case(false; "environment_profiles")] +#[test_case(true; "turn_and_session_grants")] +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn copy_file_uses_each_environments_permissions(use_grants: bool) -> Result<()> { + use codex_protocol::models::PermissionProfile; + use codex_protocol::models::PermissionProfileSnapshot; + use codex_protocol::permissions::FileSystemAccessMode; + use codex_protocol::permissions::FileSystemSandboxEntry; + use codex_protocol::permissions::FileSystemSandboxPolicy; + use codex_protocol::permissions::NetworkSandboxPolicy; + use codex_protocol::protocol::AskForApproval; + use codex_protocol::protocol::ThreadSettingsOverrides; + use codex_protocol::protocol::TurnEnvironmentSelection; + use codex_protocol::protocol::TurnEnvironmentSelections; + use codex_protocol::request_permissions::PermissionGrantScope; + use codex_protocol::request_permissions::RequestPermissionsResponse; + use codex_utils_absolute_path::AbsolutePathBuf; + use core_test_support::exec_server::ExecServerProcess; + + let source_executor = ExecServerProcess::start().await?; + let target_executor = ExecServerProcess::start().await?; + let source_root = TempDir::new()?; + let target_root = TempDir::new()?; + let source = source_root.path().join("source.bin"); + let target = target_root.path().join("target.bin"); + let denied_source = target_root.path().join("private.bin"); + let denied_target = source_root.path().join("copy.bin"); + let bytes = [0, 255, 17, 42].repeat(/*n*/ 300_000); + fs::write(&source, &bytes)?; + fs::write(&target, b"original")?; + fs::write(&denied_source, b"private")?; + let server = start_mock_server().await; + let mut extensions = ExtensionRegistryBuilder::new(); + codex_multi_env_copy_file::install(&mut extensions); + let test = test_codex() + .with_extensions(Arc::new(extensions.build())) + .with_config(|config| { + // Make the copy itself the first restricted read. On unelevated Windows the + // executor rejects this policy, which would otherwise stop AGENTS.md discovery. + config.project_doc_max_bytes = 0; + config + .features + .enable(Feature::RequestPermissionsTool) + .expect("enable request_permissions"); + }) + .build_with_auto_env(&server) + .await?; + let mut selections = Vec::new(); + for (id, executor, root, access) in [ + ( + "source", + &source_executor, + &source_root, + FileSystemAccessMode::Read, + ), + ( + "target", + &target_executor, + &target_root, + FileSystemAccessMode::Write, + ), + ] { + fs::create_dir(root.path().join("cwd"))?; + let manager = test.thread_manager.environment_manager(); + manager.upsert_environment( + id.to_string(), + executor.websocket_url.clone(), + /*connect_timeout*/ None, + )?; + manager + .get_environment(id) + .context("environment")? + .wait_until_ready() + .await?; + let cwd = PathUri::from_host_native_path(root.path().join("cwd"))?; + let mut selection = TurnEnvironmentSelection { + selected_capability_roots: Default::default(), + environment_id: id.to_string(), + cwd: cwd.clone(), + workspace_roots: vec![cwd], + config: EnvironmentConfigState::FromThread, + }; + let mut config = environment_config_for_selection(&test.config, &selection); + config.permission_profile = + PermissionProfileSnapshot::legacy(PermissionProfile::from_runtime_permissions( + &FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry::new( + AbsolutePathBuf::from_absolute_path(if use_grants { + root.path().join("cwd") + } else { + root.path().to_path_buf() + })? + .into(), + if use_grants { + FileSystemAccessMode::Read + } else { + access + }, + )]), + NetworkSandboxPolicy::Restricted, + )); + selection.config = EnvironmentConfigState::Ready(config); + selections.push(selection); + } + let mut calls = Vec::new(); + for (id, source_path, target_path) in [ + ("copy", &source, &target), + ("denied-read", &denied_source, &target), + ("denied-write", &source, &denied_target), + ] { + calls.push(ev_function_call_with_namespace( + id, + "functions", + "multi_env_copy_file", + &json!({ + "source_environment_id": "source", "source_path": source_path, + "target_environment_id": "target", "target_path": target_path, + }) + .to_string(), + )); + } + calls.push(ev_completed("copies")); + let mut responses = Vec::new(); + if use_grants { + let mut requests = Vec::new(); + for (id, root, access) in [ + ("source", &source_root, "read"), + ("target", &target_root, "write"), + ] { + requests.push(ev_function_call_with_namespace( + id, + "functions", + "request_permissions", + &json!({ + "environment_id": id, + "reason": "Copy an artifact outside the working directory", + "permissions": {"file_system": {access: [root.path()]}}, + }) + .to_string(), + )); + } + requests.push(ev_completed("permissions")); + responses.push(sse(requests)); + } + responses.extend([ + sse(calls), + sse(vec![ + ev_assistant_message("done", "done"), + ev_completed("done"), + ]), + ]); + let mock = mount_sse_sequence(&server, responses).await; + test.codex + .start_or_steer_turn( + TurnInputRequest::user_input(vec![UserInput::Text { + text: "Copy the artifact to the target environment.".to_string(), + text_elements: Vec::new(), + }]) + .with_thread_settings(ThreadSettingsOverrides { + environments: Some( + TurnEnvironmentSelections::new(test.config.cwd.clone(), selections) + .into_requests(), + ), + approval_policy: Some(AskForApproval::OnRequest), + ..Default::default() + }), + ) + .await?; + if use_grants { + for scope in [PermissionGrantScope::Turn, PermissionGrantScope::Session] { + let event = wait_for_event(&test.codex, |event| { + matches!( + event, + EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_) + ) + }) + .await; + let EventMsg::RequestPermissions(request) = event else { + anyhow::bail!("copy finished before requesting permissions"); + }; + test.codex + .submit(Op::RequestPermissionsResponse { + id: request.call_id, + response: RequestPermissionsResponse { + permissions: request.permissions, + scope, + strict_auto_review: false, + }, + }) + .await?; + } + } + wait_for_event(&test.codex, |event| { + matches!(event, EventMsg::TurnComplete(_)) + }) + .await; + let copy = mock + .function_call_output_text("copy") + .context("copy result")?; + let read_error = mock + .function_call_output_text("denied-read") + .context("read error")?; + let write_error = mock + .function_call_output_text("denied-write") + .context("write error")?; + if cfg!(windows) { + // These two executors are host-native. The unelevated Windows sandbox must reject + // a policy with restricted reads, including after granting access to a specific path. + for error in [copy, read_error, write_error] { + assert!(error.contains("could not open copy source"), "{error}"); + } + assert_eq!(fs::read(&source)?, bytes); + assert_eq!(fs::read(&target)?, b"original"); + assert!(!denied_target.try_exists()?); + return Ok(()); + } + assert_eq!( + serde_json::from_str::(©)?, + json!({"bytes_copied": bytes.len()}) + ); + assert!(read_error.contains("could not open copy source")); + assert!(write_error.contains("target may be truncated or partially written")); + // Linux hides inaccessible roots; other sandbox implementations may report a denial instead. + #[cfg(target_os = "linux")] + if !use_grants { + insta::assert_json_snapshot!("permission_errors", [read_error, write_error]); + } + assert_eq!( + (fs::read(&source)?, fs::read(&target)?), + (bytes.clone(), bytes) + ); + assert!(!denied_target.try_exists()?); + Ok(()) +} diff --git a/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__connectivity__disconnected_executor.snap b/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__connectivity__disconnected_executor.snap new file mode 100644 index 0000000000..ae84fd3481 --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__connectivity__disconnected_executor.snap @@ -0,0 +1,14 @@ +--- +source: ext/multi-env-copy-file/tests/suite/connectivity.rs +expression: outputs +--- +[ + { + "call_id": "source-disconnected", + "output": "could not open copy source: broken pipe" + }, + { + "call_id": "target-disconnected", + "output": "copy failed; target may be truncated or partially written: broken pipe" + } +] diff --git a/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__connectivity__unavailable_endpoints.snap b/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__connectivity__unavailable_endpoints.snap new file mode 100644 index 0000000000..cd9b261115 --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__connectivity__unavailable_endpoints.snap @@ -0,0 +1,8 @@ +--- +source: ext/multi-env-copy-file/tests/suite/connectivity.rs +expression: "json!({\n \"source\":\n mock.function_call_output_text(\"source-offline\").context(\"source error\")?,\n \"target\":\n mock.function_call_output_text(\"target-offline\").context(\"target error\")?,\n})" +--- +{ + "source": "unknown or unavailable environment ID: unavailable", + "target": "unknown or unavailable environment ID: unavailable" +} diff --git a/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__endpoint_errors@unix.snap b/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__endpoint_errors@unix.snap new file mode 100644 index 0000000000..5e83209b0c --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__endpoint_errors@unix.snap @@ -0,0 +1,61 @@ +--- +source: ext/multi-env-copy-file/tests/suite/mod.rs +expression: errors +--- +[ + { + "field": "source_environment_id", + "output": "missing field `source_environment_id` at line 1 column ", + "value": null + }, + { + "field": "target_environment_id", + "output": "missing field `target_environment_id` at line 1 column ", + "value": null + }, + { + "field": "source_environment_id", + "output": "unknown or unavailable environment ID: unknown", + "value": "unknown" + }, + { + "field": "target_environment_id", + "output": "unknown or unavailable environment ID: unknown", + "value": "unknown" + }, + { + "field": "source_path", + "output": "provide an absolute POSIX file path for environment ", + "value": "source.txt" + }, + { + "field": "target_path", + "output": "provide an absolute POSIX file path for environment ", + "value": "target.txt" + }, + { + "field": "source_path", + "output": "provide an absolute POSIX file path for environment ", + "value": "Z:\\foreign\\source.txt" + }, + { + "field": "source_path", + "output": "could not open copy source: entity not found", + "value": "/missing.bin" + }, + { + "field": "target_path", + "output": "copy failed; target may be truncated or partially written: is a directory", + "value": "" + }, + { + "field": "source_path", + "output": "could not open copy source: invalid input parameter", + "value": "" + }, + { + "field": "target_path", + "output": "copy failed; target may be truncated or partially written: entity not found", + "value": "/missing/target.txt" + } +] diff --git a/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__endpoint_errors@windows.snap b/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__endpoint_errors@windows.snap new file mode 100644 index 0000000000..b07434ccd9 --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__endpoint_errors@windows.snap @@ -0,0 +1,61 @@ +--- +source: ext/multi-env-copy-file/tests/suite/mod.rs +expression: errors +--- +[ + { + "field": "source_environment_id", + "output": "missing field `source_environment_id` at line 1 column ", + "value": null + }, + { + "field": "target_environment_id", + "output": "missing field `target_environment_id` at line 1 column ", + "value": null + }, + { + "field": "source_environment_id", + "output": "unknown or unavailable environment ID: unknown", + "value": "unknown" + }, + { + "field": "target_environment_id", + "output": "unknown or unavailable environment ID: unknown", + "value": "unknown" + }, + { + "field": "source_path", + "output": "provide an absolute Windows file path for environment ", + "value": "source.txt" + }, + { + "field": "target_path", + "output": "provide an absolute Windows file path for environment ", + "value": "target.txt" + }, + { + "field": "source_path", + "output": "provide an absolute Windows file path for environment ", + "value": "/foreign/source.txt" + }, + { + "field": "source_path", + "output": "could not open copy source: entity not found", + "value": "\\missing.bin" + }, + { + "field": "target_path", + "output": "copy failed; target may be truncated or partially written: ", + "value": "" + }, + { + "field": "source_path", + "output": "could not open copy source: ", + "value": "" + }, + { + "field": "target_path", + "output": "copy failed; target may be truncated or partially written: entity not found", + "value": "\\missing\\target.txt" + } +] diff --git a/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__permission_errors.snap b/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__permission_errors.snap new file mode 100644 index 0000000000..4f93a378b3 --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__permission_errors.snap @@ -0,0 +1,8 @@ +--- +source: ext/multi-env-copy-file/tests/suite/mod.rs +expression: "[read_error, write_error]" +--- +[ + "could not open copy source: entity not found", + "copy failed; target may be truncated or partially written: entity not found" +] diff --git a/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__transfers__multi_env_copy_file.snap b/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__transfers__multi_env_copy_file.snap new file mode 100644 index 0000000000..3319dc7ab6 --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/tests/suite/snapshots/copy_file__suite__transfers__multi_env_copy_file.snap @@ -0,0 +1,42 @@ +--- +source: ext/multi-env-copy-file/tests/suite/transfers.rs +expression: snapshot +--- +Scenario: File contents stay outside model context. Copies within one environment are rejected. + +## Window 1 +Settings: + include: ["reasoning.encrypted_content"] + model: "gpt-5.5" + parallel_tool_calls: true + prompt_cache_key: "" + reasoning: {"effort":"medium"} + store: false + stream: true + text: {"verbosity":"low"} + tool_choice: "auto" + tools (8; hash=a5c9313910564c2e): + - function/exec_command: Runs a command in a PTY, returning output or a session ID for ongoing interaction.; args=[cmd, environment_id, justification, login, max_output_tokens, prefix_rule, sandbox_permissions, shell, tty, workdir, yield_time_ms]; hash=e7a0fbdd42b934a5 + - function/write_stdin: Writes characters to an existing unified exec session and returns recent output.; args=[chars, max_output_tokens, session_id, yield_time_ms]; hash=7fb62d8a3e2df4e2 + - function/request_user_input: Request user input for one to three short questions and wait for the response. This tool is only ...; args=[questions]; hash=64a729b25c424fc6 + - custom/apply_patch: The `apply_patch` tool can be used to edit files. This is a FREEFORM tool, so do not wrap the pat...; hash=6673c610c90583ae + - function/view_image: View a local image file from the filesystem when visual inspection is needed. Use this for images...; args=[detail, environment_id, path]; hash=a81dcaff856f99c8 + - function/multi_env_copy_file: Copy a file between two different environments without loading it into context. Returns bytes cop...; args=[source_environment_id, source_path, target_environment_id, target_path]; hash=1fc19c22b5ee2122 + - tool_search: # Tool discovery Searches over deferred tool metadata with BM25 and exposes matching tools for t...; args=[limit, query]; hash=400cd6ac61d524a9 + - web_search; hash=e214d4db1bc49e89 +-- request 1 (request) -- +00:message/developer: + +01:message/developer: + +02:message/user: + +03:message/user: + Copy the binary artifact within and between environments. +-- request 2 (request) -- +04:function_call/functions.multi_env_copy_file:{"source_environment_id":"","source_path":"","target_environment_id":"","target_path":""} +05:function_call/functions.multi_env_copy_file:{"source_environment_id":"","source_path":"","target_environment_id":"native","target_path":""} +06:function_call/functions.multi_env_copy_file:{"source_environment_id":"native","source_path":"","target_environment_id":"","target_path":""} +07:function_call_output:source and target environment IDs must differ. To copy within one environment, use a shell command. +08:function_call_output:{"bytes_copied":1470000} +09:function_call_output:{"bytes_copied":1470000} diff --git a/codex-rs/ext/multi-env-copy-file/tests/suite/transfers.rs b/codex-rs/ext/multi-env-copy-file/tests/suite/transfers.rs new file mode 100644 index 0000000000..5209ba2aa2 --- /dev/null +++ b/codex-rs/ext/multi-env-copy-file/tests/suite/transfers.rs @@ -0,0 +1,287 @@ +//! Exercises real local, remote, and foreign-OS copy paths through model tool calls. + +use super::*; +use codex_config::ConfigLayerSource; +use codex_config::ConfigLayerStack; +use codex_models_manager::bundled_models_response; +use codex_protocol::protocol::TurnEnvironmentSelection; +use core_test_support::TempDirExt; +use core_test_support::context_snapshot; +use core_test_support::context_snapshot::ContextSnapshotOptions; +use core_test_support::context_snapshot::SnapshotEntry; +use core_test_support::responses::ResponsesRequest; +use pretty_assertions::assert_eq; + +/// The selected executor may be local, Docker, or Wine; the second executor is host-native. +/// One flow covers same-environment rejection and both directions across executor boundaries. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn copy_file_transfers_between_selected_and_native_executors() -> Result<()> { + use core_test_support::exec_server::ExecServerProcess; + + let executor = ExecServerProcess::start().await?; + let root = TempDir::new()?; + let server = start_mock_server().await; + let mut extensions = ExtensionRegistryBuilder::new(); + codex_multi_env_copy_file::install(&mut extensions); + let test = test_codex() + .with_extensions(Arc::new(extensions.build())) + .with_config(|config| { + // Keep the fixture independent of the checkout's project configuration. + let stack = &config.config_layer_stack; + config.config_layer_stack = ConfigLayerStack::new( + stack + .all_layers_low_to_high() + .filter(|layer| !matches!(&layer.name, ConfigLayerSource::Project { .. })) + .cloned() + .collect(), + stack.requirements().clone(), + stack.requirements_toml().clone(), + ) + .expect("fixture config layers"); + config.model_catalog = Some(bundled_models_response().expect("bundled model catalog")); + config.cloud_skill_enabled = false; + }) + .build_with_auto_env(&server) + .await?; + let manager = test.thread_manager.environment_manager(); + manager.upsert_environment( + "native".to_string(), + executor.websocket_url.clone(), + /*connect_timeout*/ None, + )?; + let native = manager + .get_environment("native") + .context("native environment")?; + native.wait_until_ready().await?; + let native_fs = native.get_filesystem(); + let native_cwd = PathUri::from_host_native_path(root.path())?; + let primary = test.executor_environment().selection(); + let source = test.workspace_path_uri("source.bin")?; + let native_source = native_cwd.join("source.bin")?; + let contents = + b"file contents that must not enter model context\0\xff".repeat(/*n*/ 30_000); + for (fs, path) in [(test.fs(), &source), (native_fs.clone(), &native_source)] { + fs.write_file( + path, + contents.clone(), + Default::default(), + /*sandbox*/ None, + ) + .await?; + } + let same_target = test.workspace_path_uri("same.bin")?; + let original = b"leave this file intact"; + test.fs() + .write_file( + &same_target, + original.to_vec(), + Default::default(), + /*sandbox*/ None, + ) + .await?; + let remote_target = native_cwd.join("target.bin")?; + let return_target = test.workspace_path_uri("returned.bin")?; + let cases = [ + ( + "same", + primary.environment_id.as_str(), + &source, + primary.environment_id.as_str(), + &same_target, + ), + ( + "outbound", + primary.environment_id.as_str(), + &source, + "native", + &remote_target, + ), + ( + "inbound", + "native", + &native_source, + primary.environment_id.as_str(), + &return_target, + ), + ]; + let mut calls = Vec::new(); + for (id, source_id, source, target_id, target) in &cases { + calls.push(ev_function_call_with_namespace( + id, + "functions", + "multi_env_copy_file", + &json!({ + "source_environment_id": source_id, + "source_path": source.inferred_native_path_string(), + "target_environment_id": target_id, + "target_path": target.inferred_native_path_string(), + }) + .to_string(), + )); + } + calls.push(ev_completed("copies")); + let mock = mount_sse_sequence( + &server, + vec![ + sse(calls), + sse(vec![ + ev_assistant_message("done", "done"), + ev_completed("done"), + ]), + ], + ) + .await; + test.submit_turn_with_environments( + "Copy the binary artifact within and between environments.", + Some(vec![ + primary.clone(), + TurnEnvironmentSelection { + selected_capability_roots: Default::default(), + environment_id: "native".to_string(), + cwd: native_cwd.clone(), + workspace_roots: vec![native_cwd], + config: EnvironmentConfigState::FromThread, + }, + ]), + ) + .await?; + for (id, source_id, _, target_id, target) in &cases { + let output = mock.function_call_output_text(id).context("copy result")?; + let expected_contents = if source_id == target_id { + assert!( + output.contains("source and target environment IDs must differ"), + "{output}" + ); + original.as_slice() + } else { + assert_eq!( + serde_json::from_str::(&output)?, + json!({"bytes_copied": contents.len()}) + ); + contents.as_slice() + }; + let fs = manager + .get_environment(target_id) + .context("target environment")? + .get_filesystem(); + assert_eq!( + fs.read_file(target, ReadFileOptions::default(), /*sandbox*/ None) + .await?, + expected_contents + ); + } + let requests = mock.requests(); + assert!(!requests.iter().any(|request| { + request.body_contains_text("file contents that must not enter model context") + })); + // Normalize platform-specific arguments before rendering truncates and fingerprints them. + let mut bodies = requests + .iter() + .map(ResponsesRequest::body_json) + .collect::>(); + for body in &mut bodies { + for item in body["input"].as_array_mut().context("request input")? { + if item["type"] == "function_call" && item["name"] == "multi_env_copy_file" { + let mut args: serde_json::Value = + serde_json::from_str(item["arguments"].as_str().context("copy arguments")?)?; + for endpoint in ["source", "target"] { + args[format!("{endpoint}_path")] = json!(""); + let id = format!("{endpoint}_environment_id"); + if args[&id] == primary.environment_id { + args[id] = json!(""); + } + } + item["arguments"] = json!(args.to_string()); + } + } + } + let snapshot = context_snapshot::format_context_snapshot( + "File contents stay outside model context. Copies within one environment are rejected.", + &bodies.iter().map(SnapshotEntry::body).collect::>(), + &ContextSnapshotOptions::default() + .rewrite_known_segments() + .include_request_settings(), + ); + insta::assert_snapshot!("multi_env_copy_file", snapshot); + Ok(()) +} + +/// Under wine-exec this crosses Linux/Windows with one local endpoint in both directions. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn copy_file_transfers_between_local_and_foreign_executors() -> Result<()> { + core_test_support::skip_if_no_remote_env!(Ok(())); + let local_root = TempDir::new()?; + let server = start_mock_server().await; + let mut extensions = ExtensionRegistryBuilder::new(); + codex_multi_env_copy_file::install(&mut extensions); + let test = test_codex() + .with_extensions(Arc::new(extensions.build())) + .build_with_remote_and_local_env(&server) + .await?; + let local = core_test_support::test_codex::local(local_root.abs()); + let remote = test.executor_environment().selection().clone(); + let local_source = local.cwd.join("source.bin")?; + let remote_target = remote.cwd.join("target.bin")?; + let local_target = local.cwd.join("returned.bin")?; + let contents = [0, 255, 17, 42].repeat(/*n*/ 300_000); + fs::write(local_root.path().join("source.bin"), &contents)?; + let calls = [ + ( + "outbound", + &local.environment_id, + &local_source, + &remote.environment_id, + &remote_target, + ), + ( + "inbound", + &remote.environment_id, + &remote_target, + &local.environment_id, + &local_target, + ), + ] + .into_iter() + .map(|(id, source_id, source, target_id, target)| { + ev_function_call_with_namespace(id, "functions", "multi_env_copy_file", &json!({ + "source_environment_id": source_id, "source_path": source.inferred_native_path_string(), + "target_environment_id": target_id, "target_path": target.inferred_native_path_string(), + }).to_string()) + }); + let mock = mount_sse_sequence( + &server, + vec![ + sse(calls.chain([ev_completed("copies")]).collect()), + sse(vec![ + ev_assistant_message("done", "done"), + ev_completed("done"), + ]), + ], + ) + .await; + test.submit_turn_with_environments( + "Copy the artifact to the remote environment and back.", + Some(vec![local, remote]), + ) + .await?; + for id in ["outbound", "inbound"] { + assert_eq!( + serde_json::from_str::( + &mock.function_call_output_text(id).context("copy result")? + )?, + json!({"bytes_copied": contents.len()}) + ); + } + assert_eq!( + test.fs() + .read_file( + &remote_target, + ReadFileOptions::default(), + /*sandbox*/ None + ) + .await?, + contents + ); + assert_eq!(fs::read(local_root.path().join("returned.bin"))?, contents); + Ok(()) +}