mirror of
https://github.com/openai/codex.git
synced 2026-10-19 08:16:48 +00:00
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:
committed by
copyberry
parent
088c7a1e75
commit
e7de1f4440
Generated
+28
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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>(©)?,
|
||||
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(())
|
||||
}
|
||||
+14
@@ -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"
|
||||
}
|
||||
]
|
||||
+8
@@ -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"
|
||||
}
|
||||
+61
@@ -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"
|
||||
}
|
||||
]
|
||||
+61
@@ -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"
|
||||
}
|
||||
]
|
||||
+8
@@ -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"
|
||||
]
|
||||
+42
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user