Add an opt-in tool for copying files between environments (#54674)

## What changed

Add the `codex-multi-env-copy-file` extension, exposing `multi_env_copy_file` when registered by the host. Copy files between distinct selected environments without loading their contents into model context, and return `bytes_copied` on completion.

Require absolute paths in each environment's native path convention and enforce each endpoint's sandbox policy and permission grants. Stream file contents where supported, with a buffered fallback for older executors. Reject same-environment copies and report I/O error kinds without exposing connection details. Warn that failed writes may leave a truncated or partially written target.

## Testing

Add integration tests for binary transfers in both directions, local and remote executors, path validation, opt-in tool visibility, endpoint permissions and grants, and unavailable or disconnected executors. Verify that copied contents stay outside model context.

GitOrigin-RevId: f5d9e423d105019834e0288828cb68c5711b3a49
This commit is contained in:
Adam Perry @ OpenAI
2026-10-16 18:39:31 +00:00
committed by copyberry
parent 088c7a1e75
commit e7de1f4440
17 changed files with 1504 additions and 0 deletions
+28
View File
@@ -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",
]
+1
View File
@@ -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",
@@ -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"],
)
@@ -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"] }
@@ -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<Arc<dyn for<'call> ToolExecutor<ToolCall<'call>>>> {
vec![Arc::new(CopyFileTool)]
}
}
/// Installs file copying after the host checks its feature flag.
pub fn install<C: Sync>(registry: &mut ExtensionRegistryBuilder<C>) {
registry.tool_contributor(Arc::new(FileCopyExtension));
}
@@ -0,0 +1,4 @@
mod extension;
mod tool;
pub use extension::install;
@@ -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<ToolCall<'call>> for CopyFileTool {
fn tool_name(&self) -> ToolName {
ToolName::plain(TOOL_NAME)
}
fn spec(&self) -> ToolSpec {
let fields = [
(
"source_environment_id",
"Source environment ID from <environment_context>.",
),
(
"source_path",
"Absolute file path in the source environment. Parent directories must exist.",
),
(
"target_environment_id",
"Target environment ID from <environment_context>.",
),
(
"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::<BTreeMap<_, _>>();
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<dyn codex_tools::ToolOutput>)
})
}
}
@@ -0,0 +1,5 @@
//! Integration tests for the multi-environment file-copy tool.
#![allow(clippy::expect_used)]
mod suite;
@@ -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::<Vec<_>>();
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(())
}
@@ -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(), "<CWD_URI>")
.replace(&selection.cwd.inferred_native_path_string(), "<CWD>")
.replace(
&format!("for environment {}", selection.environment_id),
"for environment <PRIMARY>",
)
};
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 <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)",
": <DIRECTORY_ERROR>",
));
"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::<serde_json::Value>(&copy)?,
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(())
}
@@ -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"
}
]
@@ -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"
}
@@ -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 <COLUMN>",
"value": null
},
{
"field": "target_environment_id",
"output": "missing field `target_environment_id` at line 1 column <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 <PRIMARY>",
"value": "source.txt"
},
{
"field": "target_path",
"output": "provide an absolute POSIX file path for environment <PRIMARY>",
"value": "target.txt"
},
{
"field": "source_path",
"output": "provide an absolute POSIX file path for environment <PRIMARY>",
"value": "Z:\\foreign\\source.txt"
},
{
"field": "source_path",
"output": "could not open copy source: entity not found",
"value": "<CWD>/missing.bin"
},
{
"field": "target_path",
"output": "copy failed; target may be truncated or partially written: is a directory",
"value": "<CWD>"
},
{
"field": "source_path",
"output": "could not open copy source: invalid input parameter",
"value": "<CWD>"
},
{
"field": "target_path",
"output": "copy failed; target may be truncated or partially written: entity not found",
"value": "<CWD>/missing/target.txt"
}
]
@@ -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 <COLUMN>",
"value": null
},
{
"field": "target_environment_id",
"output": "missing field `target_environment_id` at line 1 column <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 <PRIMARY>",
"value": "source.txt"
},
{
"field": "target_path",
"output": "provide an absolute Windows file path for environment <PRIMARY>",
"value": "target.txt"
},
{
"field": "source_path",
"output": "provide an absolute Windows file path for environment <PRIMARY>",
"value": "/foreign/source.txt"
},
{
"field": "source_path",
"output": "could not open copy source: entity not found",
"value": "<CWD>\\missing.bin"
},
{
"field": "target_path",
"output": "copy failed; target may be truncated or partially written: <DIRECTORY_ERROR>",
"value": "<CWD>"
},
{
"field": "source_path",
"output": "could not open copy source: <DIRECTORY_ERROR>",
"value": "<CWD>"
},
{
"field": "target_path",
"output": "copy failed; target may be truncated or partially written: entity not found",
"value": "<CWD>\\missing\\target.txt"
}
]
@@ -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"
]
@@ -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: "<PROMPT_CACHE_KEY 1>"
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:
<MODEL_INSTRUCTIONS>
01:message/developer:
<PERMISSIONS_INSTRUCTIONS>
02:message/user:
<ENVIRONMENT_CONTEXT>
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":"<PRIMARY>","source_path":"<ABSOLUTE_PATH>","target_environment_id":"<PRIMARY>","target_path":"<ABSOLUTE_PATH>"}
05:function_call/functions.multi_env_copy_file:{"source_environment_id":"<PRIMARY>","source_path":"<ABSOLUTE_PATH>","target_environment_id":"native","target_path":"<ABSOLUTE_PATH>"}
06:function_call/functions.multi_env_copy_file:{"source_environment_id":"native","source_path":"<ABSOLUTE_PATH>","target_environment_id":"<PRIMARY>","target_path":"<ABSOLUTE_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}
@@ -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::<serde_json::Value>(&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::<Vec<_>>();
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!("<ABSOLUTE_PATH>");
let id = format!("{endpoint}_environment_id");
if args[&id] == primary.environment_id {
args[id] = json!("<PRIMARY>");
}
}
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::<Vec<_>>(),
&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::<serde_json::Value>(
&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(())
}