mirror of
https://github.com/pchuan98/codex.git
synced 2026-07-01 00:31:56 +08:00
9f06cf1a09
## Why #29113 moved remote sandbox setup and enforcement to the exec server. That gives the executor ownership of the platform-specific work: a Linux executor chooses and runs a Linux sandbox even when the Codex orchestrator is running on macOS or Windows. It also means the orchestrator no longer knows which concrete sandbox the executor selected. When that sandbox blocks a remote command, the orchestrator currently sees only a failed process and can treat the denial as an ordinary command failure. The existing sandbox approval and retry path is then skipped. This PR lets the executor report one portable fact: > This command probably failed because the executor sandbox blocked it. The executor keeps its concrete sandbox type private. The protocol sends only the semantic result. ## Example Suppose a local macOS Codex session asks a Linux devbox to write outside the allowed workspace. Before this PR: ```text Linux sandbox blocks the write -> remote process exits with "Permission denied" -> local orchestrator sees an ordinary command failure -> the normal sandbox approval and retry path can be skipped ``` With this PR: ```text Linux sandbox blocks the write -> executor reports sandboxDenied: true -> unified exec returns UnifiedExecError::SandboxDenied -> the existing approval prompt is shown -> an approved retry runs through the existing unsandboxed retry path ``` ## What changes ### The executor remembers its selected sandbox The prepared remote process now retains the executor-selected `SandboxType`. This value never crosses the executor boundary. Commands started without a sandbox retain `SandboxType::None` and are never reported as sandbox denials. ### The executor uses the existing denial heuristic The existing local denial heuristic moves from `codex-core` into the shared `codex-sandboxing` crate. When a sandboxed remote process exits, the executor: 1. waits the same short output grace period used by local unified exec; 2. reads the output currently available in the existing retained output buffer; 3. runs the existing heuristic using the exit code and common denial messages; 4. stores the yes/no result before publishing the process exit. This deliberately matches the old local unified-exec behavior. It does not add a new streaming classifier, another output buffer, or stronger output-retention guarantees. ### The protocol reports a portable boolean `process/read` gains `sandboxDenied`: ```json { "exited": true, "exitCode": 1, "closed": false, "sandboxDenied": true } ``` The field defaults to `false` when an older executor omits it. The response does not expose the executor sandbox implementation or executor-native paths. ### Unified exec uses the existing error path The exec-server client carries `sandboxDenied` into the unified process state. If it is true, unified exec returns the existing `SandboxDenied` error instead of trying to classify remote output using an orchestrator-side sandbox type. Remote process exit remains visible as soon as the process exits. This PR does not wait for stdout or stderr to close and does not change the existing process lifecycle. ## Scope This PR is intentionally limited to matching the existing local unified-exec behavior for the initial command execution path. It does not add: - incremental denial tracking across the full output stream; - new denial handling for commands completed later through `write_stdin`; - new guarantees for preserving the semantic flag during the narrow reconnect-recovery race. Those can be considered separately if the same behavior is added for local execution. ## Test coverage One remote end-to-end integration test covers the complete intended flow: ```text remote read-only sandbox -> denied write -> executor reports the denial -> Codex requests approval -> user approves -> retry succeeds on the remote executor ``` Existing lifecycle coverage continues to verify that remote process exit is reported before late output streams close.
1677 lines
55 KiB
Rust
1677 lines
55 KiB
Rust
use anyhow::Context;
|
|
use anyhow::Result;
|
|
use codex_config::types::ApprovalsReviewer;
|
|
use codex_core::config::Constrained;
|
|
use codex_exec_server::CopyOptions;
|
|
use codex_exec_server::CreateDirectoryOptions;
|
|
use codex_exec_server::FileSystemSandboxContext;
|
|
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
|
|
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
|
|
use codex_exec_server::RemoveOptions;
|
|
use codex_features::Feature;
|
|
use codex_protocol::models::FileSystemPermissions;
|
|
use codex_protocol::models::PermissionProfile;
|
|
use codex_protocol::permissions::FileSystemAccessMode;
|
|
use codex_protocol::permissions::FileSystemPath;
|
|
use codex_protocol::permissions::FileSystemSandboxEntry;
|
|
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
|
use codex_protocol::permissions::NetworkSandboxPolicy;
|
|
use codex_protocol::protocol::ApplyPatchApprovalRequestEvent;
|
|
use codex_protocol::protocol::AskForApproval;
|
|
use codex_protocol::protocol::EventMsg;
|
|
use codex_protocol::protocol::Op;
|
|
use codex_protocol::protocol::ReviewDecision;
|
|
use codex_protocol::protocol::SandboxPolicy;
|
|
use codex_protocol::protocol::TurnEnvironmentSelection;
|
|
use codex_protocol::request_permissions::PermissionGrantScope;
|
|
use codex_protocol::request_permissions::RequestPermissionProfile;
|
|
use codex_protocol::request_permissions::RequestPermissionsResponse;
|
|
use codex_protocol::request_user_input::RequestUserInputAnswer;
|
|
use codex_protocol::request_user_input::RequestUserInputResponse;
|
|
use codex_protocol::user_input::UserInput;
|
|
use codex_utils_absolute_path::AbsolutePathBuf;
|
|
use codex_utils_path_uri::PathUri;
|
|
use core_test_support::PathBufExt;
|
|
use core_test_support::PathExt;
|
|
use core_test_support::TestEnvironment;
|
|
use core_test_support::get_remote_test_env;
|
|
use core_test_support::responses::ev_apply_patch_custom_tool_call;
|
|
use core_test_support::responses::ev_assistant_message;
|
|
use core_test_support::responses::ev_completed;
|
|
use core_test_support::responses::ev_function_call;
|
|
use core_test_support::responses::ev_response_created;
|
|
use core_test_support::responses::mount_sse_once;
|
|
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::skip_if_no_network;
|
|
use core_test_support::skip_if_wine_exec;
|
|
use core_test_support::test_codex::TestCodex;
|
|
use core_test_support::test_codex::local;
|
|
use core_test_support::test_codex::test_codex;
|
|
use core_test_support::test_codex::test_env;
|
|
use core_test_support::wait_for_event;
|
|
use core_test_support::wait_for_event_match;
|
|
use futures::SinkExt;
|
|
use futures::StreamExt;
|
|
use pretty_assertions::assert_eq;
|
|
use serde_json::Value;
|
|
use serde_json::json;
|
|
use std::collections::HashMap;
|
|
use std::fs;
|
|
use std::path::PathBuf;
|
|
use std::process::Command;
|
|
use std::time::Duration;
|
|
use std::time::SystemTime;
|
|
use std::time::UNIX_EPOCH;
|
|
use tempfile::TempDir;
|
|
use tokio::net::TcpListener;
|
|
use tokio::net::TcpStream;
|
|
use tokio::time::timeout;
|
|
use tokio_tungstenite::WebSocketStream;
|
|
use tokio_tungstenite::accept_async;
|
|
use tokio_tungstenite::tungstenite::Message;
|
|
async fn unified_exec_test(server: &wiremock::MockServer) -> Result<TestCodex> {
|
|
let mut builder = test_codex().with_config(|config| {
|
|
config.use_experimental_unified_exec_tool = true;
|
|
let result = config.features.enable(Feature::UnifiedExec);
|
|
assert!(
|
|
result.is_ok(),
|
|
"unified exec should enable for test: {result:?}",
|
|
);
|
|
});
|
|
builder.build_with_remote_and_local_env(server).await
|
|
}
|
|
|
|
async fn submit_turn_with_approval_and_environments(
|
|
test: &TestCodex,
|
|
prompt: &str,
|
|
environments: Vec<TurnEnvironmentSelection>,
|
|
approval_policy: AskForApproval,
|
|
) -> Result<()> {
|
|
let turn_environment_selections = codex_protocol::protocol::TurnEnvironmentSelections::new(
|
|
test.config.cwd.clone(),
|
|
environments,
|
|
);
|
|
test.codex
|
|
.submit(Op::UserInput {
|
|
items: vec![UserInput::Text {
|
|
text: prompt.into(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
final_output_json_schema: None,
|
|
responsesapi_client_metadata: None,
|
|
additional_context: Default::default(),
|
|
thread_settings: codex_protocol::protocol::ThreadSettingsOverrides {
|
|
environments: Some(turn_environment_selections),
|
|
approval_policy: Some(approval_policy),
|
|
approvals_reviewer: Some(ApprovalsReviewer::User),
|
|
sandbox_policy: Some(SandboxPolicy::new_read_only_policy()),
|
|
collaboration_mode: Some(codex_protocol::config_types::CollaborationMode {
|
|
mode: codex_protocol::config_types::ModeKind::Default,
|
|
settings: codex_protocol::config_types::Settings {
|
|
model: test.session_configured.model.clone(),
|
|
reasoning_effort: None,
|
|
developer_instructions: None,
|
|
},
|
|
}),
|
|
..Default::default()
|
|
},
|
|
})
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn expect_patch_approval(
|
|
test: &TestCodex,
|
|
expected_call_id: &str,
|
|
) -> ApplyPatchApprovalRequestEvent {
|
|
let event = wait_for_event(&test.codex, |event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
})
|
|
.await;
|
|
|
|
match event {
|
|
EventMsg::ApplyPatchApprovalRequest(approval) => {
|
|
assert_eq!(approval.call_id, expected_call_id);
|
|
approval
|
|
}
|
|
EventMsg::TurnComplete(_) => panic!("expected patch approval request before completion"),
|
|
other => panic!("unexpected event: {other:?}"),
|
|
}
|
|
}
|
|
|
|
async fn wait_for_completion_without_patch_approval(test: &TestCodex) {
|
|
let event = wait_for_event(&test.codex, |event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ApplyPatchApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
})
|
|
.await;
|
|
|
|
match event {
|
|
EventMsg::TurnComplete(_) => {}
|
|
EventMsg::ApplyPatchApprovalRequest(event) => {
|
|
panic!("unexpected patch approval request: {:?}", event.call_id)
|
|
}
|
|
other => panic!("unexpected event: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> {
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let test_env = test_env().await?;
|
|
let file_system = test_env.environment().get_filesystem();
|
|
|
|
let file_path_abs = test_env.cwd().join("remote-test-env-ok");
|
|
let file_path_uri = PathUri::from_path(&file_path_abs)?;
|
|
let payload = b"remote-test-env-ok".to_vec();
|
|
|
|
file_system
|
|
.write_file(&file_path_uri, payload.clone(), /*sandbox*/ None)
|
|
.await?;
|
|
let actual = file_system
|
|
.read_file(&file_path_uri, /*sandbox*/ None)
|
|
.await?;
|
|
assert_eq!(actual, payload);
|
|
|
|
file_system
|
|
.remove(
|
|
&file_path_uri,
|
|
RemoveOptions {
|
|
recursive: false,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_test_env_exposes_target_shell_to_model() -> Result<()> {
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let server = start_mock_server().await;
|
|
let response_mock = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "done"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let test = test_codex().build_with_remote_env(&server).await?;
|
|
|
|
test.submit_turn("report remote environment").await?;
|
|
|
|
let request = response_mock.single_request();
|
|
let environment_context = request
|
|
.message_input_texts("user")
|
|
.into_iter()
|
|
.find(|text| text.starts_with("<environment_context>"))
|
|
.context("environment context should be model visible")?;
|
|
// TODO(anp): Assert Wine-exec exposes a `C:\\...` cwd after model-visible paths preserve
|
|
// target-native spelling instead of the Linux orchestrator's `/C:/...` representation.
|
|
let expected_shell = match core_test_support::test_environment() {
|
|
TestEnvironment::Docker { .. } => "<shell>bash</shell>",
|
|
TestEnvironment::WineExec => "<shell>powershell</shell>",
|
|
TestEnvironment::Local => unreachable!("test requires a remote environment"),
|
|
};
|
|
assert_eq!(
|
|
environment_context
|
|
.lines()
|
|
.find(|line| line.trim_start().starts_with("<shell>"))
|
|
.map(str::trim),
|
|
Some(expected_shell),
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn explicit_remote_shell_runs_in_remote_cwd() -> Result<()> {
|
|
const CALL_ID: &str = "remote-explicit-shell";
|
|
|
|
let (shell, command) = match core_test_support::test_environment() {
|
|
TestEnvironment::Docker { .. } => (
|
|
"bash",
|
|
r#"case "$PWD" in /tmp/codex-core-test-cwd-*) ;; *) echo "unexpected cwd: $PWD" >&2; exit 1 ;; esac"#,
|
|
),
|
|
TestEnvironment::WineExec => (
|
|
"powershell",
|
|
r#"$cwd = (Get-Location).Path; if ($cwd -notlike 'C:\codex-core-test-cwd-*') { Write-Error "unexpected cwd: $cwd"; exit 1 }"#,
|
|
),
|
|
TestEnvironment::Local => return Ok(()),
|
|
};
|
|
|
|
let server = start_mock_server().await;
|
|
let arguments = serde_json::to_string(&json!({
|
|
"cmd": command,
|
|
"shell": shell,
|
|
"login": false,
|
|
"yield_time_ms": 10_000,
|
|
}))?;
|
|
let mut builder = test_codex().with_config(|config| {
|
|
config.use_experimental_unified_exec_tool = true;
|
|
config
|
|
.features
|
|
.enable(Feature::UnifiedExec)
|
|
.expect("test config should allow feature update");
|
|
});
|
|
let test = builder.build_with_remote_env(&server).await?;
|
|
let response_mock = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(CALL_ID, "exec_command", &arguments),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "done"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
test.submit_turn_with_environments(
|
|
"run the remote shell in the remote cwd",
|
|
Some(vec![TurnEnvironmentSelection {
|
|
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
|
cwd: PathUri::from_abs_path(&test.config.cwd),
|
|
}]),
|
|
)
|
|
.await?;
|
|
let request = response_mock
|
|
.last_request()
|
|
.context("model should receive the command output")?;
|
|
let (output, success) = request
|
|
.function_call_output_content_and_success(CALL_ID)
|
|
.context("remote shell tool result should be present")?;
|
|
assert_ne!(success, Some(false));
|
|
assert!(
|
|
output.is_some_and(|output| output.contains("Process exited with code 0")),
|
|
"remote shell command should exit successfully",
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_sandbox_denial_requests_approval_and_retries() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
skip_if_wine_exec!(Ok(()), "requires the Docker-backed POSIX executor");
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
const CALL_ID: &str = "remote-sandbox-denial";
|
|
const CONTENTS: &str = "remote sandbox retry succeeded";
|
|
|
|
let server = start_mock_server().await;
|
|
let test = unified_exec_test(&server).await?;
|
|
let nonce = SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis();
|
|
let remote_cwd = PathBuf::from(format!("/tmp/codex-remote-denial-cwd-{nonce}")).abs();
|
|
let target_path = PathBuf::from(format!("/tmp/codex-remote-denial-target-{nonce}")).abs();
|
|
let remote_cwd_uri = PathUri::from_path(&remote_cwd)?;
|
|
let target_uri = PathUri::from_path(&target_path)?;
|
|
test.fs()
|
|
.create_directory(
|
|
&remote_cwd_uri,
|
|
CreateDirectoryOptions { recursive: true },
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
test.fs()
|
|
.remove(
|
|
&target_uri,
|
|
RemoveOptions {
|
|
recursive: false,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
let command = format!("printf {CONTENTS:?} > {target_path:?} && cat {target_path:?}");
|
|
let response_mock = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-remote-denial-1"),
|
|
ev_function_call(
|
|
CALL_ID,
|
|
"exec_command",
|
|
&json!({
|
|
"shell": "/bin/sh",
|
|
"cmd": command,
|
|
"login": false,
|
|
"yield_time_ms": 5_000,
|
|
"environment_id": REMOTE_ENVIRONMENT_ID,
|
|
})
|
|
.to_string(),
|
|
),
|
|
ev_completed("resp-remote-denial-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-remote-denial-2"),
|
|
ev_assistant_message("msg-remote-denial", "done"),
|
|
ev_completed("resp-remote-denial-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
submit_turn_with_approval_and_environments(
|
|
&test,
|
|
"retry a sandbox-denied command in the remote environment",
|
|
vec![TurnEnvironmentSelection {
|
|
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
|
cwd: PathUri::from_abs_path(&remote_cwd),
|
|
}],
|
|
AskForApproval::OnFailure,
|
|
)
|
|
.await?;
|
|
|
|
let event = wait_for_event(&test.codex, |event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
})
|
|
.await;
|
|
let EventMsg::ExecApprovalRequest(approval) = event else {
|
|
panic!("expected remote sandbox approval before completion: {event:?}");
|
|
};
|
|
assert_eq!(approval.call_id, CALL_ID);
|
|
assert_eq!(
|
|
approval.environment_id.as_deref(),
|
|
Some(REMOTE_ENVIRONMENT_ID)
|
|
);
|
|
assert_eq!(
|
|
approval.reason.as_deref(),
|
|
Some("command failed; retry without sandbox?")
|
|
);
|
|
|
|
test.codex
|
|
.submit(Op::ExecApproval {
|
|
id: approval.effective_approval_id(),
|
|
turn_id: None,
|
|
decision: ReviewDecision::Approved,
|
|
})
|
|
.await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
|
|
assert!(
|
|
response_mock
|
|
.function_call_output_text(CALL_ID)
|
|
.is_some_and(|output| output.contains(CONTENTS)),
|
|
"approved retry should return the remote command output"
|
|
);
|
|
assert_eq!(
|
|
test.fs()
|
|
.read_file_text(&target_uri, /*sandbox*/ None)
|
|
.await?,
|
|
CONTENTS
|
|
);
|
|
|
|
test.fs()
|
|
.remove(
|
|
&target_uri,
|
|
RemoveOptions {
|
|
recursive: false,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
test.fs()
|
|
.remove(
|
|
&remote_cwd_uri,
|
|
RemoveOptions {
|
|
recursive: true,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn deferred_executor_does_not_duplicate_initial_environment_context() -> Result<()> {
|
|
let server = start_mock_server().await;
|
|
let response_mock = mount_sse_once(
|
|
&server,
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_assistant_message("msg-1", "done"),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
)
|
|
.await;
|
|
let mut builder = test_codex().with_config(|config| {
|
|
assert!(config.features.enable(Feature::DeferredExecutor).is_ok());
|
|
});
|
|
let test = builder.build(&server).await?;
|
|
|
|
test.submit_turn("report the environment").await?;
|
|
|
|
let user_context = response_mock.single_request().message_input_texts("user");
|
|
assert_eq!(
|
|
user_context
|
|
.iter()
|
|
.filter(|text| text.contains("<environment_context>"))
|
|
.count(),
|
|
1
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn read_exec_server_json(websocket: &mut WebSocketStream<TcpStream>) -> Value {
|
|
loop {
|
|
match timeout(Duration::from_secs(5), websocket.next())
|
|
.await
|
|
.expect("websocket read should not time out")
|
|
.expect("websocket should stay open")
|
|
.expect("websocket frame should read")
|
|
{
|
|
Message::Text(text) => {
|
|
return serde_json::from_str(text.as_ref()).expect("valid JSON-RPC message");
|
|
}
|
|
Message::Binary(bytes) => {
|
|
return serde_json::from_slice(bytes.as_ref()).expect("valid JSON-RPC message");
|
|
}
|
|
Message::Ping(_) | Message::Pong(_) => {}
|
|
other => panic!("expected JSON-RPC message, got {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn serve_environment_info(listener: TcpListener) {
|
|
let (stream, _) = listener.accept().await.expect("connection");
|
|
let mut websocket = accept_async(stream).await.expect("websocket handshake");
|
|
|
|
let initialize = read_exec_server_json(&mut websocket).await;
|
|
assert_eq!(initialize["method"], "initialize");
|
|
websocket
|
|
.send(Message::Text(
|
|
json!({
|
|
"id": initialize["id"],
|
|
"result": { "sessionId": "test-session" }
|
|
})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.expect("initialize response");
|
|
let initialized = read_exec_server_json(&mut websocket).await;
|
|
assert_eq!(initialized["method"], "initialized");
|
|
|
|
let info = read_exec_server_json(&mut websocket).await;
|
|
assert_eq!(info["method"], "environment/info");
|
|
websocket
|
|
.send(Message::Text(
|
|
json!({
|
|
"id": info["id"],
|
|
"result": { "shell": { "name": "zsh", "path": "/bin/zsh" } }
|
|
})
|
|
.to_string()
|
|
.into(),
|
|
))
|
|
.await
|
|
.expect("environment info response");
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn deferred_executor_updates_model_context_after_startup() -> Result<()> {
|
|
let listener = TcpListener::bind("127.0.0.1:0").await?;
|
|
let server = start_mock_server().await;
|
|
let user_input_call_id = "wait-for-startup";
|
|
let response_mock = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(
|
|
user_input_call_id,
|
|
"request_user_input",
|
|
&json!({
|
|
"questions": [{
|
|
"id": "continue",
|
|
"header": "Continue",
|
|
"question": "Continue after startup?",
|
|
"options": [{
|
|
"label": "Yes (Recommended)",
|
|
"description": "Continue the test."
|
|
}, {
|
|
"label": "No",
|
|
"description": "Stop the test."
|
|
}]
|
|
}]
|
|
})
|
|
.to_string(),
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_function_call(
|
|
"update-plan",
|
|
"update_plan",
|
|
&json!({
|
|
"explanation": "Continue after startup.",
|
|
"plan": [{"step": "Finish", "status": "completed"}]
|
|
})
|
|
.to_string(),
|
|
),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-3"),
|
|
ev_assistant_message("msg-3", "done"),
|
|
ev_completed("resp-3"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
let mut builder = test_codex()
|
|
.with_exec_server_url(format!("ws://{}", listener.local_addr()?))
|
|
.with_config(|config| {
|
|
assert!(config.features.enable(Feature::DeferredExecutor).is_ok());
|
|
assert!(
|
|
config
|
|
.features
|
|
.enable(Feature::DefaultModeRequestUserInput)
|
|
.is_ok()
|
|
);
|
|
});
|
|
let test = timeout(Duration::from_secs(5), builder.build(&server))
|
|
.await
|
|
.context("thread startup should not wait for the remote environment")??;
|
|
|
|
test.codex
|
|
.submit(Op::UserInput {
|
|
items: vec![UserInput::Text {
|
|
text: "wait for the environment".into(),
|
|
text_elements: Vec::new(),
|
|
}],
|
|
final_output_json_schema: None,
|
|
responsesapi_client_metadata: None,
|
|
additional_context: Default::default(),
|
|
thread_settings: Default::default(),
|
|
})
|
|
.await?;
|
|
let request = wait_for_event_match(&test.codex, |event| match event {
|
|
EventMsg::RequestUserInput(request) => Some(request.clone()),
|
|
_ => None,
|
|
})
|
|
.await;
|
|
|
|
serve_environment_info(listener).await;
|
|
test.codex
|
|
.submit(Op::UserInputAnswer {
|
|
id: request.turn_id,
|
|
response: RequestUserInputResponse {
|
|
answers: HashMap::from([(
|
|
"continue".to_string(),
|
|
RequestUserInputAnswer {
|
|
answers: vec!["Yes (Recommended)".to_string()],
|
|
},
|
|
)]),
|
|
},
|
|
})
|
|
.await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
|
|
let requests = response_mock.requests();
|
|
assert_eq!(requests.len(), 3);
|
|
assert!(
|
|
requests[0]
|
|
.message_input_texts("user")
|
|
.iter()
|
|
.any(|text| text.contains("<status>starting</status>"))
|
|
);
|
|
let ready_user_context = requests[1].message_input_texts("user");
|
|
assert_eq!(
|
|
ready_user_context
|
|
.iter()
|
|
.filter(|text| text.contains("<shell>zsh</shell>"))
|
|
.count(),
|
|
1
|
|
);
|
|
let final_user_context = requests[2].message_input_texts("user");
|
|
assert_eq!(
|
|
final_user_context
|
|
.iter()
|
|
.filter(|text| text.contains("<status>starting</status>"))
|
|
.count(),
|
|
1
|
|
);
|
|
assert_eq!(
|
|
final_user_context
|
|
.iter()
|
|
.filter(|text| text.contains("<shell>zsh</shell>"))
|
|
.count(),
|
|
1
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
fn absolute_path(path: PathBuf) -> AbsolutePathBuf {
|
|
AbsolutePathBuf::try_from(path).expect("path should be absolute")
|
|
}
|
|
|
|
fn read_only_sandbox(readable_root: PathBuf) -> FileSystemSandboxContext {
|
|
let readable_root = absolute_path(readable_root);
|
|
FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions(
|
|
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: readable_root,
|
|
},
|
|
access: FileSystemAccessMode::Read,
|
|
}]),
|
|
NetworkSandboxPolicy::Restricted,
|
|
))
|
|
}
|
|
|
|
fn workspace_write_sandbox(writable_root: PathBuf) -> FileSystemSandboxContext {
|
|
let writable_root = absolute_path(writable_root);
|
|
FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions(
|
|
&FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
|
path: FileSystemPath::Path {
|
|
path: writable_root,
|
|
},
|
|
access: FileSystemAccessMode::Write,
|
|
}]),
|
|
NetworkSandboxPolicy::Restricted,
|
|
))
|
|
}
|
|
|
|
fn assert_normalized_path_rejected(error: &std::io::Error) {
|
|
match error.kind() {
|
|
std::io::ErrorKind::NotFound => assert!(
|
|
error.to_string().contains("No such file or directory"),
|
|
"unexpected not-found message: {error}",
|
|
),
|
|
std::io::ErrorKind::InvalidInput | std::io::ErrorKind::PermissionDenied => {
|
|
let message = error.to_string();
|
|
assert!(
|
|
message.contains("is not permitted")
|
|
|| message.contains("Operation not permitted")
|
|
|| message.contains("Permission denied"),
|
|
"unexpected rejection message: {message}",
|
|
);
|
|
}
|
|
other => panic!("unexpected normalized-path error kind: {other:?}: {error:?}"),
|
|
}
|
|
}
|
|
|
|
fn remote_exec(script: &str) -> Result<()> {
|
|
let remote_env = get_remote_test_env().context("remote env should be configured")?;
|
|
let container_name = remote_env
|
|
.docker_container_name()
|
|
.context("test requires direct access to the Docker container")?;
|
|
let output = Command::new("docker")
|
|
.args(["exec", container_name, "sh", "-lc", script])
|
|
.output()?;
|
|
assert!(
|
|
output.status.success(),
|
|
"remote exec failed: stdout={} stderr={}",
|
|
String::from_utf8_lossy(&output.stdout).trim(),
|
|
String::from_utf8_lossy(&output.stderr).trim(),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn exec_command_routing_output(
|
|
test: &TestCodex,
|
|
server: &wiremock::MockServer,
|
|
call_id: &str,
|
|
arguments: Value,
|
|
environments: Option<Vec<TurnEnvironmentSelection>>,
|
|
) -> Result<String> {
|
|
let response_mock = mount_sse_sequence(
|
|
server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(call_id, "exec_command", &serde_json::to_string(&arguments)?),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "done"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
test.submit_turn_with_environments("route exec command", environments)
|
|
.await?;
|
|
|
|
response_mock
|
|
.function_call_output_text(call_id)
|
|
.with_context(|| format!("missing function_call_output for {call_id}"))
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn exec_command_routes_to_selected_remote_environment() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
// TODO(anp): Remove after remote path fixtures use target-native paths.
|
|
skip_if_wine_exec!(Ok(()), "requires the Docker-backed POSIX executor");
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let server = start_mock_server().await;
|
|
let test = unified_exec_test(&server).await?;
|
|
let local_cwd = TempDir::new()?;
|
|
fs::write(local_cwd.path().join("marker.txt"), "local-routing")?;
|
|
let local_selection = local(local_cwd.path().abs());
|
|
let remote_cwd = PathBuf::from(format!(
|
|
"/tmp/codex-remote-routing-{}",
|
|
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
|
))
|
|
.abs();
|
|
let remote_marker_name = "marker.txt";
|
|
let remote_cwd_uri = PathUri::from_path(&remote_cwd)?;
|
|
let remote_marker_uri = PathUri::from_path(remote_cwd.join(remote_marker_name))?;
|
|
test.fs()
|
|
.create_directory(
|
|
&remote_cwd_uri,
|
|
CreateDirectoryOptions { recursive: true },
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
test.fs()
|
|
.write_file(
|
|
&remote_marker_uri,
|
|
b"remote-routing".to_vec(),
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
let remote_selection = TurnEnvironmentSelection {
|
|
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
|
cwd: PathUri::from_abs_path(&remote_cwd),
|
|
};
|
|
let multi_env_output = exec_command_routing_output(
|
|
&test,
|
|
&server,
|
|
"call-multi-env",
|
|
json!({
|
|
"shell": "/bin/sh",
|
|
"cmd": format!("cat {remote_marker_name}"),
|
|
"login": false,
|
|
"yield_time_ms": 1_000,
|
|
"environment_id": REMOTE_ENVIRONMENT_ID,
|
|
}),
|
|
Some(vec![local_selection, remote_selection]),
|
|
)
|
|
.await?;
|
|
assert!(
|
|
multi_env_output.contains("remote-routing"),
|
|
"unexpected multi-env output: {multi_env_output}",
|
|
);
|
|
assert!(
|
|
!multi_env_output.contains("local-routing"),
|
|
"multi-env command should not route to local: {multi_env_output}",
|
|
);
|
|
|
|
test.fs()
|
|
.remove(
|
|
&remote_cwd_uri,
|
|
RemoveOptions {
|
|
recursive: true,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_request_permissions_grant_unblocks_later_remote_exec() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
// TODO(anp): Remove after remote path fixtures use target-native paths.
|
|
skip_if_wine_exec!(Ok(()), "requires the Docker-backed POSIX executor");
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let server = start_mock_server().await;
|
|
let mut builder = test_codex().with_config(|config| {
|
|
config.use_experimental_unified_exec_tool = true;
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
config.approvals_reviewer = ApprovalsReviewer::User;
|
|
config
|
|
.features
|
|
.enable(Feature::UnifiedExec)
|
|
.expect("test config should allow feature update");
|
|
config
|
|
.features
|
|
.enable(Feature::ExecPermissionApprovals)
|
|
.expect("test config should allow feature update");
|
|
config
|
|
.features
|
|
.enable(Feature::RequestPermissionsTool)
|
|
.expect("test config should allow feature update");
|
|
});
|
|
let test = builder.build_with_remote_and_local_env(&server).await?;
|
|
|
|
let local_cwd = TempDir::new()?;
|
|
let remote_cwd = PathBuf::from(format!(
|
|
"/tmp/codex-remote-request-permissions-{}",
|
|
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
|
))
|
|
.abs();
|
|
let relative_write_root = "granted";
|
|
let relative_target_path = "granted/request-permissions-output.txt";
|
|
let remote_write_root = remote_cwd.join(relative_write_root);
|
|
let remote_target_path = remote_cwd.join(relative_target_path);
|
|
let local_write_root = local_cwd.path().join(relative_write_root);
|
|
let local_target_path = local_cwd.path().join(relative_target_path);
|
|
fs::create_dir(&local_write_root)?;
|
|
let remote_write_root_uri = PathUri::from_path(&remote_write_root)?;
|
|
test.fs()
|
|
.create_directory(
|
|
&remote_write_root_uri,
|
|
CreateDirectoryOptions { recursive: true },
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
let expected_permissions = RequestPermissionProfile {
|
|
file_system: Some(FileSystemPermissions::from_read_write_roots(
|
|
Some(vec![]),
|
|
Some(vec![remote_write_root.clone()]),
|
|
)),
|
|
..RequestPermissionProfile::default()
|
|
};
|
|
let approved_response = RequestPermissionsResponse {
|
|
permissions: expected_permissions.clone(),
|
|
scope: PermissionGrantScope::Turn,
|
|
strict_auto_review: false,
|
|
};
|
|
let command = format!(
|
|
"printf 'remote-request-permissions-ok' > {relative_target_path} && cat {relative_target_path}"
|
|
);
|
|
let response_mock = mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-request-permissions-remote-1"),
|
|
ev_function_call(
|
|
"permissions-call",
|
|
"request_permissions",
|
|
&json!({
|
|
"environment_id": REMOTE_ENVIRONMENT_ID,
|
|
"reason": "Allow writing inside the selected remote environment",
|
|
"permissions": {
|
|
"file_system": {
|
|
"write": [relative_write_root],
|
|
},
|
|
},
|
|
})
|
|
.to_string(),
|
|
),
|
|
ev_completed("resp-request-permissions-remote-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-request-permissions-remote-2"),
|
|
ev_function_call(
|
|
"exec-call",
|
|
"exec_command",
|
|
&json!({
|
|
"shell": "/bin/sh",
|
|
"cmd": command,
|
|
"login": false,
|
|
"yield_time_ms": 1_000,
|
|
"environment_id": REMOTE_ENVIRONMENT_ID,
|
|
})
|
|
.to_string(),
|
|
),
|
|
ev_completed("resp-request-permissions-remote-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-request-permissions-remote-3"),
|
|
ev_assistant_message("msg-request-permissions-remote-1", "done"),
|
|
ev_completed("resp-request-permissions-remote-3"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
submit_turn_with_approval_and_environments(
|
|
&test,
|
|
"request permissions, then write in the remote environment",
|
|
vec![
|
|
local(local_cwd.path().abs()),
|
|
TurnEnvironmentSelection {
|
|
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
|
cwd: PathUri::from_abs_path(&remote_cwd),
|
|
},
|
|
],
|
|
AskForApproval::OnRequest,
|
|
)
|
|
.await?;
|
|
|
|
let event = wait_for_event(&test.codex, |event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::RequestPermissions(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
})
|
|
.await;
|
|
let EventMsg::RequestPermissions(request) = event else {
|
|
panic!("expected remote request_permissions before completion: {event:?}");
|
|
};
|
|
assert_eq!(request.call_id, "permissions-call");
|
|
assert_eq!(
|
|
request.environment_id.as_deref(),
|
|
Some(REMOTE_ENVIRONMENT_ID)
|
|
);
|
|
assert_eq!(request.cwd.as_ref(), Some(&remote_cwd));
|
|
assert_eq!(request.permissions, expected_permissions);
|
|
|
|
test.codex
|
|
.submit(Op::RequestPermissionsResponse {
|
|
id: "permissions-call".to_string(),
|
|
response: approved_response.clone(),
|
|
})
|
|
.await?;
|
|
|
|
let event = wait_for_event(&test.codex, |event| {
|
|
matches!(
|
|
event,
|
|
EventMsg::ExecApprovalRequest(_) | EventMsg::TurnComplete(_)
|
|
)
|
|
})
|
|
.await;
|
|
match event {
|
|
EventMsg::TurnComplete(_) => {}
|
|
EventMsg::ExecApprovalRequest(approval) => {
|
|
panic!("remote request_permissions grant should preapprove exec: {approval:?}");
|
|
}
|
|
other => panic!("unexpected event: {other:?}"),
|
|
}
|
|
|
|
let permissions_output: RequestPermissionsResponse = serde_json::from_str(
|
|
&response_mock
|
|
.function_call_output_text("permissions-call")
|
|
.expect("expected request_permissions output"),
|
|
)?;
|
|
assert_eq!(permissions_output, approved_response);
|
|
let exec_output = response_mock
|
|
.function_call_output_text("exec-call")
|
|
.expect("expected exec output");
|
|
assert!(
|
|
exec_output.contains("remote-request-permissions-ok"),
|
|
"unexpected exec output: {exec_output}",
|
|
);
|
|
assert_eq!(
|
|
test.fs()
|
|
.read_file_text(
|
|
&PathUri::from_path(&remote_target_path)?,
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?,
|
|
"remote-request-permissions-ok"
|
|
);
|
|
assert!(
|
|
!local_target_path.exists(),
|
|
"remote exec should not write through the local environment"
|
|
);
|
|
|
|
test.fs()
|
|
.remove(
|
|
&PathUri::from_abs_path(&remote_cwd),
|
|
RemoveOptions {
|
|
recursive: true,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn apply_patch_freeform_routes_to_selected_remote_environment() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
// TODO(anp): Remove after remote path fixtures use target-native paths.
|
|
skip_if_wine_exec!(Ok(()), "requires the Docker-backed POSIX executor");
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let server = start_mock_server().await;
|
|
let mut builder = test_codex();
|
|
let test = builder.build_with_remote_and_local_env(&server).await?;
|
|
let local_cwd = TempDir::new()?;
|
|
let file_name = "apply_patch_remote_freeform.txt";
|
|
let remote_cwd = PathBuf::from(format!(
|
|
"/tmp/codex-remote-apply-patch-freeform-{}",
|
|
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
|
))
|
|
.abs();
|
|
let remote_cwd_uri = PathUri::from_path(&remote_cwd)?;
|
|
test.fs()
|
|
.create_directory(
|
|
&remote_cwd_uri,
|
|
CreateDirectoryOptions { recursive: true },
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
let patch = format!(
|
|
"*** Begin Patch\n*** Environment ID: {REMOTE_ENVIRONMENT_ID}\n*** Add File: {file_name}\n+patched remote freeform\n*** End Patch"
|
|
);
|
|
let call_id = "apply-patch-remote-freeform";
|
|
mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_apply_patch_custom_tool_call(call_id, &patch),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "done"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
test.submit_turn_with_environments(
|
|
"apply patch to remote environment",
|
|
Some(vec![
|
|
local(local_cwd.path().abs()),
|
|
TurnEnvironmentSelection {
|
|
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
|
cwd: PathUri::from_abs_path(&remote_cwd),
|
|
},
|
|
]),
|
|
)
|
|
.await?;
|
|
|
|
let remote_contents = test
|
|
.fs()
|
|
.read_file_text(
|
|
&PathUri::from_path(remote_cwd.join(file_name))?,
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
assert_eq!(remote_contents, "patched remote freeform\n");
|
|
assert!(
|
|
!local_cwd.path().join(file_name).exists(),
|
|
"freeform apply_patch should not create the file in the local environment"
|
|
);
|
|
|
|
test.fs()
|
|
.remove(
|
|
&remote_cwd_uri,
|
|
RemoveOptions {
|
|
recursive: true,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn apply_patch_approvals_are_remembered_per_environment() -> Result<()> {
|
|
skip_if_no_network!(Ok(()));
|
|
// TODO(anp): Remove after remote path fixtures use target-native paths.
|
|
skip_if_wine_exec!(Ok(()), "requires the Docker-backed POSIX executor");
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let server = start_mock_server().await;
|
|
let mut builder = test_codex().with_config(|config| {
|
|
config.permissions.approval_policy = Constrained::allow_any(AskForApproval::OnRequest);
|
|
config.approvals_reviewer = ApprovalsReviewer::User;
|
|
});
|
|
let test = builder.build_with_remote_and_local_env(&server).await?;
|
|
let local_cwd = TempDir::new()?;
|
|
let remote_cwd = PathBuf::from(format!(
|
|
"/tmp/codex-remote-apply-patch-approval-cwd-{}",
|
|
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
|
))
|
|
.abs();
|
|
let remote_cwd_uri = PathUri::from_path(&remote_cwd)?;
|
|
test.fs()
|
|
.create_directory(
|
|
&remote_cwd_uri,
|
|
CreateDirectoryOptions { recursive: true },
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
let target_path = PathBuf::from(format!(
|
|
"/tmp/codex-apply-patch-approval-scope-{}.txt",
|
|
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
|
))
|
|
.abs();
|
|
let target_path_uri = PathUri::from_path(&target_path)?;
|
|
let _ = fs::remove_file(&target_path);
|
|
test.fs()
|
|
.remove(
|
|
&target_path_uri,
|
|
RemoveOptions {
|
|
recursive: false,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
let environments = vec![
|
|
local(local_cwd.path().abs()),
|
|
TurnEnvironmentSelection {
|
|
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
|
cwd: PathUri::from_abs_path(&remote_cwd),
|
|
},
|
|
];
|
|
let local_patch = format!(
|
|
"*** Begin Patch\n*** Environment ID: {LOCAL_ENVIRONMENT_ID}\n*** Add File: {}\n+local\n*** End Patch",
|
|
target_path.display()
|
|
);
|
|
let remote_patch = format!(
|
|
"*** Begin Patch\n*** Environment ID: {REMOTE_ENVIRONMENT_ID}\n*** Add File: {}\n+remote\n*** End Patch",
|
|
target_path.display()
|
|
);
|
|
let remote_update_patch = format!(
|
|
"*** Begin Patch\n*** Environment ID: {REMOTE_ENVIRONMENT_ID}\n*** Update File: {}\n@@\n-remote\n+remote updated\n*** End Patch",
|
|
target_path.display()
|
|
);
|
|
|
|
mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-local-1"),
|
|
ev_apply_patch_custom_tool_call("call-local", &local_patch),
|
|
ev_completed("resp-local-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-local-2"),
|
|
ev_assistant_message("msg-local", "done"),
|
|
ev_completed("resp-local-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-remote-1"),
|
|
ev_apply_patch_custom_tool_call("call-remote", &remote_patch),
|
|
ev_completed("resp-remote-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-remote-2"),
|
|
ev_assistant_message("msg-remote", "done"),
|
|
ev_completed("resp-remote-2"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-remote-3"),
|
|
ev_apply_patch_custom_tool_call("call-remote-followup", &remote_update_patch),
|
|
ev_completed("resp-remote-3"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-remote-4"),
|
|
ev_assistant_message("msg-remote-followup", "done"),
|
|
ev_completed("resp-remote-4"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
submit_turn_with_approval_and_environments(
|
|
&test,
|
|
"apply patch in local environment",
|
|
environments.clone(),
|
|
AskForApproval::OnRequest,
|
|
)
|
|
.await?;
|
|
let approval = expect_patch_approval(&test, "call-local").await;
|
|
test.codex
|
|
.submit(Op::PatchApproval {
|
|
id: approval.call_id,
|
|
decision: ReviewDecision::ApprovedForSession,
|
|
})
|
|
.await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
assert_eq!(fs::read_to_string(&target_path)?, "local\n");
|
|
|
|
submit_turn_with_approval_and_environments(
|
|
&test,
|
|
"apply patch in remote environment",
|
|
environments.clone(),
|
|
AskForApproval::OnRequest,
|
|
)
|
|
.await?;
|
|
let approval = expect_patch_approval(&test, "call-remote").await;
|
|
test.codex
|
|
.submit(Op::PatchApproval {
|
|
id: approval.call_id,
|
|
decision: ReviewDecision::ApprovedForSession,
|
|
})
|
|
.await?;
|
|
wait_for_event(&test.codex, |event| {
|
|
matches!(event, EventMsg::TurnComplete(_))
|
|
})
|
|
.await;
|
|
assert_eq!(
|
|
test.fs()
|
|
.read_file_text(&target_path_uri, /*sandbox*/ None)
|
|
.await?,
|
|
"remote\n"
|
|
);
|
|
|
|
submit_turn_with_approval_and_environments(
|
|
&test,
|
|
"apply patch again in remote environment",
|
|
environments,
|
|
AskForApproval::OnRequest,
|
|
)
|
|
.await?;
|
|
wait_for_completion_without_patch_approval(&test).await;
|
|
assert_eq!(
|
|
test.fs()
|
|
.read_file_text(&target_path_uri, /*sandbox*/ None)
|
|
.await?,
|
|
"remote updated\n"
|
|
);
|
|
|
|
let _ = fs::remove_file(&target_path);
|
|
test.fs()
|
|
.remove(
|
|
&target_path_uri,
|
|
RemoveOptions {
|
|
recursive: false,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
test.fs()
|
|
.remove(
|
|
&remote_cwd_uri,
|
|
RemoveOptions {
|
|
recursive: true,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn apply_patch_intercepted_exec_command_routes_to_selected_remote_environment() -> Result<()>
|
|
{
|
|
skip_if_no_network!(Ok(()));
|
|
// TODO(anp): Remove after remote path fixtures use target-native paths.
|
|
skip_if_wine_exec!(Ok(()), "requires the Docker-backed POSIX executor");
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let server = start_mock_server().await;
|
|
let test = unified_exec_test(&server).await?;
|
|
let local_cwd = TempDir::new()?;
|
|
let file_name = "apply_patch_remote_exec.txt";
|
|
let remote_cwd = PathBuf::from(format!(
|
|
"/tmp/codex-remote-apply-patch-exec-{}",
|
|
SystemTime::now().duration_since(UNIX_EPOCH)?.as_millis()
|
|
))
|
|
.abs();
|
|
let remote_cwd_uri = PathUri::from_path(&remote_cwd)?;
|
|
test.fs()
|
|
.create_directory(
|
|
&remote_cwd_uri,
|
|
CreateDirectoryOptions { recursive: true },
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
let patch =
|
|
format!("*** Begin Patch\n*** Add File: {file_name}\n+patched remote exec\n*** End Patch");
|
|
let command = format!("apply_patch <<'EOF'\n{patch}\nEOF\n");
|
|
let call_id = "apply-patch-remote-exec";
|
|
mount_sse_sequence(
|
|
&server,
|
|
vec![
|
|
sse(vec![
|
|
ev_response_created("resp-1"),
|
|
ev_function_call(
|
|
call_id,
|
|
"exec_command",
|
|
&serde_json::to_string(&json!({
|
|
"shell": "/bin/sh",
|
|
"cmd": command,
|
|
"login": false,
|
|
"yield_time_ms": 5_000,
|
|
"environment_id": REMOTE_ENVIRONMENT_ID,
|
|
}))?,
|
|
),
|
|
ev_completed("resp-1"),
|
|
]),
|
|
sse(vec![
|
|
ev_response_created("resp-2"),
|
|
ev_assistant_message("msg-1", "done"),
|
|
ev_completed("resp-2"),
|
|
]),
|
|
],
|
|
)
|
|
.await;
|
|
|
|
test.submit_turn_with_environments(
|
|
"apply patch through exec command to remote environment",
|
|
Some(vec![
|
|
local(local_cwd.path().abs()),
|
|
TurnEnvironmentSelection {
|
|
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
|
|
cwd: PathUri::from_abs_path(&remote_cwd),
|
|
},
|
|
]),
|
|
)
|
|
.await?;
|
|
|
|
let remote_contents = test
|
|
.fs()
|
|
.read_file_text(
|
|
&PathUri::from_path(remote_cwd.join(file_name))?,
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
assert_eq!(remote_contents, "patched remote exec\n");
|
|
assert!(
|
|
!local_cwd.path().join(file_name).exists(),
|
|
"intercepted apply_patch should not create the file in the local environment"
|
|
);
|
|
|
|
test.fs()
|
|
.remove(
|
|
&remote_cwd_uri,
|
|
RemoveOptions {
|
|
recursive: true,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_test_env_sandboxed_read_allows_readable_root() -> Result<()> {
|
|
// TODO(anp): Remove after remote sandbox fixtures use target-native paths.
|
|
skip_if_wine_exec!(Ok(()), "requires the Docker-backed POSIX executor");
|
|
skip_if_no_network!(Ok(()));
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let test_env = test_env().await?;
|
|
let file_system = test_env.environment().get_filesystem();
|
|
|
|
let allowed_dir = PathBuf::from(format!("/tmp/codex-remote-readable-{}", std::process::id()));
|
|
let file_path = allowed_dir.join("note.txt");
|
|
let allowed_dir_uri = PathUri::from_path(&allowed_dir)?;
|
|
let file_path_uri = PathUri::from_path(&file_path)?;
|
|
file_system
|
|
.create_directory(
|
|
&allowed_dir_uri,
|
|
CreateDirectoryOptions { recursive: true },
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
file_system
|
|
.write_file(
|
|
&file_path_uri,
|
|
b"sandboxed hello".to_vec(),
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
let sandbox = read_only_sandbox(allowed_dir.clone());
|
|
let contents = file_system
|
|
.read_file(&file_path_uri, Some(&sandbox))
|
|
.await?;
|
|
assert_eq!(contents, b"sandboxed hello");
|
|
|
|
file_system
|
|
.remove(
|
|
&allowed_dir_uri,
|
|
RemoveOptions {
|
|
recursive: true,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_test_env_sandboxed_read_rejects_symlink_parent_dotdot_escape() -> Result<()> {
|
|
skip_if_wine_exec!(Ok(()), "tests POSIX symlink and parent traversal semantics");
|
|
skip_if_no_network!(Ok(()));
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let test_env = test_env().await?;
|
|
let file_system = test_env.environment().get_filesystem();
|
|
|
|
let root = PathBuf::from(format!("/tmp/codex-remote-dotdot-{}", std::process::id()));
|
|
let allowed_dir = root.join("allowed");
|
|
let outside_dir = root.join("outside");
|
|
let secret_path = root.join("secret.txt");
|
|
remote_exec(&format!(
|
|
"rm -rf {root}; mkdir -p {allowed} {outside}; printf nope > {secret}; ln -s {outside} {allowed}/link",
|
|
root = root.display(),
|
|
allowed = allowed_dir.display(),
|
|
outside = outside_dir.display(),
|
|
secret = secret_path.display(),
|
|
))?;
|
|
|
|
let requested_path =
|
|
PathUri::from_path(allowed_dir.join("link").join("..").join("secret.txt"))?;
|
|
let sandbox = read_only_sandbox(allowed_dir.clone());
|
|
let error = match file_system.read_file(&requested_path, Some(&sandbox)).await {
|
|
Ok(_) => anyhow::bail!("read should fail after path normalization"),
|
|
Err(error) => error,
|
|
};
|
|
assert_normalized_path_rejected(&error);
|
|
|
|
remote_exec(&format!("rm -rf {}", root.display()))?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_test_env_remove_removes_symlink_not_target() -> Result<()> {
|
|
skip_if_wine_exec!(Ok(()), "tests POSIX symlink removal semantics");
|
|
skip_if_no_network!(Ok(()));
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let test_env = test_env().await?;
|
|
let file_system = test_env.environment().get_filesystem();
|
|
|
|
let root = PathBuf::from(format!(
|
|
"/tmp/codex-remote-remove-link-{}",
|
|
std::process::id()
|
|
));
|
|
let allowed_dir = root.join("allowed");
|
|
let outside_file = root.join("outside").join("keep.txt");
|
|
let symlink_path = allowed_dir.join("link");
|
|
remote_exec(&format!(
|
|
"rm -rf {root}; mkdir -p {allowed} {outside_parent}; printf outside > {outside}; ln -s {outside} {symlink}",
|
|
root = root.display(),
|
|
allowed = allowed_dir.display(),
|
|
outside_parent = absolute_path(
|
|
outside_file
|
|
.parent()
|
|
.context("outside parent should exist")?
|
|
.to_path_buf(),
|
|
)
|
|
.display(),
|
|
outside = outside_file.display(),
|
|
symlink = symlink_path.display(),
|
|
))?;
|
|
|
|
let sandbox = workspace_write_sandbox(allowed_dir.clone());
|
|
file_system
|
|
.remove(
|
|
&PathUri::from_path(&symlink_path)?,
|
|
RemoveOptions {
|
|
recursive: false,
|
|
force: false,
|
|
},
|
|
Some(&sandbox),
|
|
)
|
|
.await?;
|
|
|
|
let symlink_exists = file_system
|
|
.get_metadata(
|
|
&PathUri::from_abs_path(&absolute_path(symlink_path)),
|
|
/*sandbox*/ None,
|
|
)
|
|
.await
|
|
.is_ok();
|
|
assert!(!symlink_exists);
|
|
let outside = file_system
|
|
.read_file_text(&PathUri::from_path(&outside_file)?, /*sandbox*/ None)
|
|
.await?;
|
|
assert_eq!(outside, "outside");
|
|
|
|
file_system
|
|
.remove(
|
|
&PathUri::from_path(&root)?,
|
|
RemoveOptions {
|
|
recursive: true,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
|
async fn remote_test_env_copy_preserves_symlink_source() -> Result<()> {
|
|
skip_if_wine_exec!(Ok(()), "tests POSIX symlink copy semantics");
|
|
skip_if_no_network!(Ok(()));
|
|
let Some(_remote_env) = get_remote_test_env() else {
|
|
return Ok(());
|
|
};
|
|
|
|
let test_env = test_env().await?;
|
|
let file_system = test_env.environment().get_filesystem();
|
|
|
|
let root = PathBuf::from(format!(
|
|
"/tmp/codex-remote-copy-link-{}",
|
|
std::process::id()
|
|
));
|
|
let allowed_dir = root.join("allowed");
|
|
let outside_file = root.join("outside").join("outside.txt");
|
|
let source_symlink = allowed_dir.join("link");
|
|
let copied_symlink = allowed_dir.join("copied-link");
|
|
remote_exec(&format!(
|
|
"rm -rf {root}; mkdir -p {allowed} {outside_parent}; printf outside > {outside}; ln -s {outside} {source}",
|
|
root = root.display(),
|
|
allowed = allowed_dir.display(),
|
|
outside_parent = outside_file.parent().expect("outside parent").display(),
|
|
outside = outside_file.display(),
|
|
source = source_symlink.display(),
|
|
))?;
|
|
|
|
let sandbox = workspace_write_sandbox(allowed_dir.clone());
|
|
file_system
|
|
.copy(
|
|
&PathUri::from_path(&source_symlink)?,
|
|
&PathUri::from_path(&copied_symlink)?,
|
|
CopyOptions { recursive: false },
|
|
Some(&sandbox),
|
|
)
|
|
.await?;
|
|
|
|
let remote_env = get_remote_test_env().context("remote env should be configured")?;
|
|
let container_name = remote_env
|
|
.docker_container_name()
|
|
.context("test requires direct access to the Docker container")?;
|
|
let link_target = Command::new("docker")
|
|
.args([
|
|
"exec",
|
|
container_name,
|
|
"readlink",
|
|
copied_symlink
|
|
.to_str()
|
|
.context("copied symlink path should be utf-8")?,
|
|
])
|
|
.output()?;
|
|
assert!(
|
|
link_target.status.success(),
|
|
"readlink failed: stdout={} stderr={}",
|
|
String::from_utf8_lossy(&link_target.stdout).trim(),
|
|
String::from_utf8_lossy(&link_target.stderr).trim(),
|
|
);
|
|
assert_eq!(
|
|
String::from_utf8_lossy(&link_target.stdout).trim(),
|
|
outside_file.to_string_lossy()
|
|
);
|
|
|
|
file_system
|
|
.remove(
|
|
&PathUri::from_path(&root)?,
|
|
RemoveOptions {
|
|
recursive: true,
|
|
force: true,
|
|
},
|
|
/*sandbox*/ None,
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|