mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Support sandboxed file streaming in exec-server (#38356)
## Why Streaming reads previously rejected requests that used a platform filesystem sandbox. ## What changed - Open streamed files in the sandbox helper and return the open file to the exec-server by passing a file descriptor on Unix or duplicating a file handle on Windows. - Advertise support through the `sandboxedFileStreaming` environment capability. - Preserve close-on-exec behavior for transferred descriptors, including the required inherited-descriptor cleanup on macOS. ## Testing - Cover bounded sandboxed streams, continued reads after path replacement, and rejection of symlink escapes outside readable roots. GitOrigin-RevId: 677b2444b74e834b78b87a8554bc119c1c6e08b2
This commit is contained in:
Generated
+1
@@ -3028,6 +3028,7 @@ dependencies = [
|
||||
"pretty_assertions",
|
||||
"prost",
|
||||
"rcgen",
|
||||
"rustix 1.1.4",
|
||||
"rustls",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -398,6 +398,7 @@ regex-lite = "0.1.8"
|
||||
reqwest = { version = "0.12", features = ["cookies"] }
|
||||
rmcp = { version = "=3.0.0", default-features = false }
|
||||
runfiles = { git = "https://github.com/dzbarsky/rules_rust", rev = "b56cbaa8465e74127f1ea216f813cd377295ad81" }
|
||||
rustix = { version = "1.1.4", features = ["net"] }
|
||||
rustls = { version = "0.23", default-features = false, features = [
|
||||
"aws_lc_rs",
|
||||
"std",
|
||||
|
||||
@@ -75,6 +75,11 @@ pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io
|
||||
if let Some(network) = network {
|
||||
network.apply_to_env(&mut env);
|
||||
}
|
||||
// macOS fd cleanup must keep the shell escalation socket.
|
||||
#[cfg(target_os = "macos")]
|
||||
let inherited_fd = env
|
||||
.get(codex_shell_escalation::ESCALATE_SOCKET_ENV_VAR)
|
||||
.and_then(|fd| fd.parse().ok());
|
||||
cmd.env_clear();
|
||||
cmd.envs(env);
|
||||
|
||||
@@ -103,6 +108,9 @@ pub(crate) async fn spawn_child_async(request: SpawnChildRequest<'_>) -> std::io
|
||||
// current parent dies."
|
||||
codex_utils_pty::process_group::set_parent_death_signal(parent_pid)?;
|
||||
}
|
||||
// macOS cannot receive the fd with close-on-exec set atomically.
|
||||
#[cfg(target_os = "macos")]
|
||||
codex_utils_pty::pty::close_inherited_fds_except(inherited_fd.as_slice());
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
|
||||
@@ -118,6 +118,9 @@ pub struct EnvironmentCapabilities {
|
||||
/// Whether this executor supports the `environmentConfig/read` request.
|
||||
#[serde(default)]
|
||||
pub environment_config_read: bool,
|
||||
/// Whether filesystem streams can use the requested platform sandbox.
|
||||
#[serde(default)]
|
||||
pub sandboxed_file_streaming: bool,
|
||||
}
|
||||
|
||||
/// Status returned by an initialized exec-server connection.
|
||||
@@ -179,6 +182,7 @@ impl EnvironmentInfo {
|
||||
network_proxy_launch: true,
|
||||
capability_discovery_sandbox: true,
|
||||
environment_config_read: true,
|
||||
sandboxed_file_streaming: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -931,6 +935,7 @@ mod tests {
|
||||
network_proxy_launch: true,
|
||||
capability_discovery_sandbox: true,
|
||||
environment_config_read: false,
|
||||
sandboxed_file_streaming: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -945,6 +950,7 @@ mod tests {
|
||||
"networkProxyLaunch": false,
|
||||
"capabilityDiscoverySandbox": false,
|
||||
"environmentConfigRead": false,
|
||||
"sandboxedFileStreaming": false,
|
||||
},
|
||||
});
|
||||
let info: EnvironmentInfo = serde_json::from_value(expected.clone())
|
||||
|
||||
@@ -58,11 +58,13 @@ uuid = { workspace = true, features = ["v4"] }
|
||||
|
||||
[target.'cfg(unix)'.dependencies]
|
||||
libc = { workspace = true }
|
||||
rustix = { workspace = true }
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.52", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Threading",
|
||||
] }
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -14,6 +14,7 @@ use crate::protocol::FS_CANONICALIZE_METHOD;
|
||||
use crate::protocol::FS_COPY_METHOD;
|
||||
use crate::protocol::FS_CREATE_DIRECTORY_METHOD;
|
||||
use crate::protocol::FS_GET_METADATA_METHOD;
|
||||
use crate::protocol::FS_OPEN_METHOD;
|
||||
use crate::protocol::FS_READ_DIRECTORY_METHOD;
|
||||
use crate::protocol::FS_READ_FILE_METHOD;
|
||||
use crate::protocol::FS_REMOVE_METHOD;
|
||||
@@ -47,6 +48,8 @@ pub const CODEX_FS_HELPER_ARG1: &str = "--codex-run-as-fs-helper";
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "params")]
|
||||
pub(crate) enum FsHelperRequest {
|
||||
#[serde(rename = "fs/open")]
|
||||
Open(FsReadFileParams),
|
||||
#[serde(rename = "fs/readFile")]
|
||||
ReadFile(FsReadFileParams),
|
||||
#[serde(rename = "fs/writeFile")]
|
||||
@@ -74,9 +77,22 @@ pub(crate) enum FsHelperResponse {
|
||||
Error(JSONRPCErrorError),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub(crate) struct FsHelperOpenResponse {
|
||||
// Windows duplicates the handle from the helper process.
|
||||
#[cfg(windows)]
|
||||
pub(crate) process_id: u32,
|
||||
// Unix passes the fd directly instead.
|
||||
#[cfg(windows)]
|
||||
pub(crate) file_handle: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "operation", content = "response")]
|
||||
pub(crate) enum FsHelperPayload {
|
||||
#[serde(rename = "fs/open")]
|
||||
Open(FsHelperOpenResponse),
|
||||
#[serde(rename = "fs/readFile")]
|
||||
ReadFile(FsReadFileResponse),
|
||||
#[serde(rename = "fs/writeFile")]
|
||||
@@ -100,6 +116,7 @@ pub(crate) enum FsHelperPayload {
|
||||
impl FsHelperPayload {
|
||||
fn operation(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Open(_) => FS_OPEN_METHOD,
|
||||
Self::ReadFile(_) => FS_READ_FILE_METHOD,
|
||||
Self::WriteFile(_) => FS_WRITE_FILE_METHOD,
|
||||
Self::CreateDirectory(_) => FS_CREATE_DIRECTORY_METHOD,
|
||||
@@ -203,6 +220,9 @@ pub(crate) async fn run_direct_request(
|
||||
) -> Result<FsHelperPayload, JSONRPCErrorError> {
|
||||
let file_system = DirectFileSystem;
|
||||
match request {
|
||||
FsHelperRequest::Open(_) => Err(invalid_request(
|
||||
"opening a file requires descriptor handoff".to_string(),
|
||||
)),
|
||||
FsHelperRequest::ReadFile(params) => {
|
||||
let data = file_system
|
||||
.read_file(¶ms.path, /*sandbox*/ None)
|
||||
@@ -316,7 +336,7 @@ pub(crate) async fn run_direct_request(
|
||||
}
|
||||
}
|
||||
|
||||
fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
|
||||
pub(crate) fn map_fs_error(err: io::Error) -> JSONRPCErrorError {
|
||||
match err.kind() {
|
||||
io::ErrorKind::NotFound => not_found(err.to_string()),
|
||||
io::ErrorKind::InvalidInput | io::ErrorKind::PermissionDenied => {
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
use std::error::Error;
|
||||
|
||||
use tokio::io;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::io::BufReader;
|
||||
|
||||
use crate::fs_helper::FsHelperOpenResponse;
|
||||
use crate::fs_helper::FsHelperPayload;
|
||||
use crate::fs_helper::FsHelperRequest;
|
||||
use crate::fs_helper::FsHelperResponse;
|
||||
use crate::fs_helper::map_fs_error;
|
||||
use crate::fs_helper::run_direct_request;
|
||||
use crate::regular_file;
|
||||
|
||||
pub fn main() -> ! {
|
||||
let exit_code = match tokio::runtime::Builder::new_current_thread()
|
||||
@@ -29,10 +34,40 @@ pub fn main() -> ! {
|
||||
}
|
||||
|
||||
async fn run_main() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
let mut input = Vec::new();
|
||||
io::stdin().read_to_end(&mut input).await?;
|
||||
let request: FsHelperRequest = serde_json::from_slice(&input)?;
|
||||
let response = match run_direct_request(request).await {
|
||||
let mut stdin = BufReader::new(io::stdin());
|
||||
let mut input = String::new();
|
||||
stdin.read_line(&mut input).await?;
|
||||
let request: FsHelperRequest = serde_json::from_str(&input)?;
|
||||
let mut opened_file = None;
|
||||
let result = match request {
|
||||
FsHelperRequest::Open(params) => {
|
||||
let result: io::Result<_> = async {
|
||||
let path = params.path.to_abs_path()?;
|
||||
let file = regular_file::open(path.as_path()).await?;
|
||||
// Unix can hand the opened fd directly to the parent.
|
||||
#[cfg(unix)]
|
||||
crate::sandboxed_file_open::transfer_file(&file)?;
|
||||
let response = FsHelperOpenResponse {
|
||||
// Windows duplicates from the helper process instead.
|
||||
#[cfg(windows)]
|
||||
process_id: std::process::id(),
|
||||
// The parent needs the raw handle to duplicate it.
|
||||
#[cfg(windows)]
|
||||
file_handle: {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
|
||||
file.as_raw_handle() as usize as u64
|
||||
},
|
||||
};
|
||||
opened_file = Some(file);
|
||||
Ok(FsHelperPayload::Open(response))
|
||||
}
|
||||
.await;
|
||||
result.map_err(map_fs_error)
|
||||
}
|
||||
request => run_direct_request(request).await,
|
||||
};
|
||||
let response = match result {
|
||||
Ok(payload) => FsHelperResponse::Ok(payload),
|
||||
Err(error) => FsHelperResponse::Error(error),
|
||||
};
|
||||
@@ -41,5 +76,16 @@ async fn run_main() -> Result<(), Box<dyn Error + Send + Sync>> {
|
||||
.write_all(serde_json::to_string(&response)?.as_bytes())
|
||||
.await?;
|
||||
stdout.write_all(b"\n").await?;
|
||||
stdout.flush().await?;
|
||||
|
||||
// Keep the Windows handle alive until the parent duplicates it.
|
||||
#[cfg(windows)]
|
||||
if opened_file.is_some() {
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
let mut acknowledgement = Vec::new();
|
||||
stdin.read_to_end(&mut acknowledgement).await?;
|
||||
}
|
||||
drop(opened_file);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -67,6 +67,15 @@ impl FileSystemSandboxRunner {
|
||||
sandbox: &FileSystemSandboxContext,
|
||||
request: FsHelperRequest,
|
||||
) -> Result<FsHelperPayload, JSONRPCErrorError> {
|
||||
let command = self.sandbox_command(sandbox)?;
|
||||
let request_json = serde_json::to_vec(&request).map_err(json_error)?;
|
||||
run_command(command, request_json).await
|
||||
}
|
||||
|
||||
pub(crate) fn sandbox_command(
|
||||
&self,
|
||||
sandbox: &FileSystemSandboxContext,
|
||||
) -> Result<SandboxExecRequest, JSONRPCErrorError> {
|
||||
let cwd = sandbox_cwd(sandbox)?;
|
||||
let native_workspace_roots = sandbox
|
||||
.workspace_roots
|
||||
@@ -98,10 +107,7 @@ impl FileSystemSandboxRunner {
|
||||
&file_system_policy,
|
||||
network_policy,
|
||||
);
|
||||
let command =
|
||||
self.sandbox_exec_request(&permission_profile, &cwd, workspace_roots, sandbox)?;
|
||||
let request_json = serde_json::to_vec(&request).map_err(json_error)?;
|
||||
run_command(command, request_json).await
|
||||
self.sandbox_exec_request(&permission_profile, &cwd, workspace_roots, sandbox)
|
||||
}
|
||||
|
||||
fn sandbox_exec_request(
|
||||
@@ -305,7 +311,7 @@ async fn run_command(
|
||||
command: SandboxExecRequest,
|
||||
request_json: Vec<u8>,
|
||||
) -> Result<FsHelperPayload, JSONRPCErrorError> {
|
||||
let mut child = spawn_command(command)?;
|
||||
let mut child = spawn_command(command, std::process::Stdio::piped())?;
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
@@ -314,6 +320,17 @@ async fn run_command(
|
||||
stdin.shutdown().await.map_err(io_error)?;
|
||||
drop(stdin);
|
||||
|
||||
let output = wait_for_helper_output(child).await?;
|
||||
let response: FsHelperResponse = serde_json::from_slice(&output.stdout).map_err(json_error)?;
|
||||
match response {
|
||||
FsHelperResponse::Ok(payload) => Ok(payload),
|
||||
FsHelperResponse::Error(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) async fn wait_for_helper_output(
|
||||
child: tokio::process::Child,
|
||||
) -> Result<std::process::Output, JSONRPCErrorError> {
|
||||
let output = child.wait_with_output().await.map_err(io_error)?;
|
||||
if !output.status.success() {
|
||||
return Err(internal_error(format!(
|
||||
@@ -322,14 +339,10 @@ async fn run_command(
|
||||
stderr = String::from_utf8_lossy(&output.stderr).trim()
|
||||
)));
|
||||
}
|
||||
let response: FsHelperResponse = serde_json::from_slice(&output.stdout).map_err(json_error)?;
|
||||
match response {
|
||||
FsHelperResponse::Ok(payload) => Ok(payload),
|
||||
FsHelperResponse::Error(error) => Err(error),
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn spawn_command(
|
||||
pub(crate) fn spawn_command(
|
||||
SandboxExecRequest {
|
||||
command: argv,
|
||||
cwd,
|
||||
@@ -337,6 +350,7 @@ fn spawn_command(
|
||||
arg0,
|
||||
..
|
||||
}: SandboxExecRequest,
|
||||
stdin: std::process::Stdio,
|
||||
) -> Result<tokio::process::Child, JSONRPCErrorError> {
|
||||
let Some((program, args)) = argv.split_first() else {
|
||||
return Err(invalid_request("fs sandbox command was empty".to_string()));
|
||||
@@ -355,14 +369,23 @@ fn spawn_command(
|
||||
env.retain(|name, _| !codex_protocol::shell_environment::is_non_inheritable_env_var(name));
|
||||
command.env_clear();
|
||||
command.envs(env);
|
||||
command.stdin(std::process::Stdio::piped());
|
||||
command.stdin(stdin);
|
||||
command.stdout(std::process::Stdio::piped());
|
||||
command.stderr(std::process::Stdio::piped());
|
||||
command.kill_on_drop(true);
|
||||
// macOS cannot receive passed fds with close-on-exec set atomically.
|
||||
#[cfg(target_os = "macos")]
|
||||
// SAFETY: Descriptor cleanup only uses fork-safe system calls.
|
||||
unsafe {
|
||||
command.pre_exec(|| {
|
||||
codex_utils_pty::pty::close_inherited_fds_except(&[]);
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
command.spawn().map_err(io_error)
|
||||
}
|
||||
|
||||
fn io_error(err: std::io::Error) -> JSONRPCErrorError {
|
||||
pub(crate) fn io_error(err: std::io::Error) -> JSONRPCErrorError {
|
||||
internal_error(err.to_string())
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ mod resolved_capability;
|
||||
mod rpc;
|
||||
mod rpc_server_requests;
|
||||
mod runtime_paths;
|
||||
mod sandboxed_file_open;
|
||||
mod sandboxed_file_system;
|
||||
mod server;
|
||||
mod telemetry;
|
||||
|
||||
@@ -99,10 +99,7 @@ impl LocalFileSystem {
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<tokio::fs::File> {
|
||||
if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"streaming file reads do not support platform sandboxing",
|
||||
));
|
||||
return self.sandboxed()?.open_file_for_read(path, sandbox).await;
|
||||
}
|
||||
self.unsandboxed.open_file_for_read(path, sandbox).await
|
||||
}
|
||||
|
||||
@@ -100,12 +100,6 @@ impl RemoteFileSystem {
|
||||
path: &PathUri,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<FileSystemReadStream> {
|
||||
if sandbox.is_some_and(FileSystemSandboxContext::should_run_in_sandbox) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"streaming file reads do not support platform sandboxing",
|
||||
));
|
||||
}
|
||||
trace!("remote fs read_file_stream");
|
||||
let client = self.client.get().await.map_err(map_remote_error)?;
|
||||
file_stream::open(client, path.clone(), remote_sandbox_context(sandbox)).await
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
use codex_exec_server_protocol::JSONRPCErrorError;
|
||||
use codex_sandboxing::SandboxExecRequest;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use tokio::io;
|
||||
|
||||
use crate::fs_helper::FsHelperOpenResponse;
|
||||
use crate::fs_helper::FsHelperPayload;
|
||||
use crate::fs_helper::FsHelperRequest;
|
||||
use crate::fs_helper::FsHelperResponse;
|
||||
use crate::fs_sandbox::io_error;
|
||||
use crate::fs_sandbox::spawn_command;
|
||||
use crate::fs_sandbox::wait_for_helper_output;
|
||||
use crate::protocol::FsReadFileParams;
|
||||
use crate::rpc::internal_error;
|
||||
use crate::rpc::invalid_request;
|
||||
|
||||
pub(crate) async fn open(
|
||||
command: SandboxExecRequest,
|
||||
path: PathUri,
|
||||
) -> Result<tokio::fs::File, JSONRPCErrorError> {
|
||||
let request = serde_json::to_vec(&FsHelperRequest::Open(FsReadFileParams {
|
||||
path,
|
||||
sandbox: None,
|
||||
}))
|
||||
.map_err(|error| internal_error(format!("invalid fs sandbox helper request: {error}")))?;
|
||||
open_platform(command, request).await
|
||||
}
|
||||
|
||||
fn open_response(response: &[u8]) -> Result<FsHelperOpenResponse, JSONRPCErrorError> {
|
||||
match serde_json::from_slice(response).map_err(|error| {
|
||||
internal_error(format!("invalid fs sandbox helper open response: {error}"))
|
||||
})? {
|
||||
FsHelperResponse::Ok(FsHelperPayload::Open(response)) => Ok(response),
|
||||
FsHelperResponse::Ok(_) => Err(invalid_request(
|
||||
"invalid fs sandbox helper open response".to_string(),
|
||||
)),
|
||||
FsHelperResponse::Error(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
// Unix passes the opened fd over the helper's stdin socket.
|
||||
#[cfg(unix)]
|
||||
async fn open_platform(
|
||||
command: SandboxExecRequest,
|
||||
request: Vec<u8>,
|
||||
) -> Result<tokio::fs::File, JSONRPCErrorError> {
|
||||
use std::io::Write;
|
||||
use std::os::fd::OwnedFd;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
let (mut receiver, sender) = UnixStream::pair().map_err(io_error)?;
|
||||
let sender: OwnedFd = sender.into();
|
||||
let child = spawn_command(command, std::process::Stdio::from(sender))?;
|
||||
receiver.write_all(&request).map_err(io_error)?;
|
||||
receiver
|
||||
.shutdown(std::net::Shutdown::Write)
|
||||
.map_err(io_error)?;
|
||||
|
||||
let output = wait_for_helper_output(child).await?;
|
||||
open_response(&output.stdout)?;
|
||||
let descriptor = receive_file_descriptor(&receiver).map_err(io_error)?;
|
||||
Ok(tokio::fs::File::from_std(std::fs::File::from(descriptor)))
|
||||
}
|
||||
|
||||
// Windows duplicates the helper's handle before letting it exit.
|
||||
#[cfg(windows)]
|
||||
async fn open_platform(
|
||||
command: SandboxExecRequest,
|
||||
mut request: Vec<u8>,
|
||||
) -> Result<tokio::fs::File, JSONRPCErrorError> {
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
let mut child = spawn_command(command, std::process::Stdio::piped())?;
|
||||
let mut stdin = child
|
||||
.stdin
|
||||
.take()
|
||||
.ok_or_else(|| internal_error("missing fs sandbox helper stdin".to_string()))?;
|
||||
let stdout = child
|
||||
.stdout
|
||||
.take()
|
||||
.ok_or_else(|| internal_error("missing fs sandbox helper stdout".to_string()))?;
|
||||
request.push(b'\n');
|
||||
stdin.write_all(&request).await.map_err(io_error)?;
|
||||
|
||||
let result = async {
|
||||
let mut response = Vec::new();
|
||||
tokio::io::BufReader::new(stdout)
|
||||
.read_until(b'\n', &mut response)
|
||||
.await
|
||||
.map_err(io_error)?;
|
||||
let response = open_response(&response)?;
|
||||
duplicate_file_handle(response.process_id, response.file_handle).map_err(io_error)
|
||||
}
|
||||
.await;
|
||||
drop(stdin);
|
||||
wait_for_helper_output(child).await?;
|
||||
result.map(tokio::fs::File::from_std)
|
||||
}
|
||||
|
||||
// SCM_RIGHTS is Unix-only.
|
||||
#[cfg(unix)]
|
||||
pub(crate) fn transfer_file(file: &tokio::fs::File) -> io::Result<()> {
|
||||
use rustix::net::SendAncillaryBuffer;
|
||||
use rustix::net::SendAncillaryMessage;
|
||||
use rustix::net::SendFlags;
|
||||
use std::io::IoSlice;
|
||||
use std::os::fd::AsFd;
|
||||
|
||||
let descriptors = [file.as_fd()];
|
||||
let mut space = [std::mem::MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(1))];
|
||||
let mut control = SendAncillaryBuffer::new(&mut space);
|
||||
if !control.push(SendAncillaryMessage::ScmRights(&descriptors)) {
|
||||
return Err(io::Error::other("missing file-descriptor control header"));
|
||||
}
|
||||
if rustix::net::sendmsg(
|
||||
std::io::stdin(),
|
||||
&[IoSlice::new(&[0])],
|
||||
&mut control,
|
||||
SendFlags::empty(),
|
||||
)? != 1
|
||||
{
|
||||
return Err(io::Error::other(
|
||||
"fs sandbox helper did not transfer its opened file descriptor",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// File-descriptor passing is only available on Unix.
|
||||
#[cfg(unix)]
|
||||
fn receive_file_descriptor(
|
||||
socket: &std::os::unix::net::UnixStream,
|
||||
) -> io::Result<std::os::fd::OwnedFd> {
|
||||
use rustix::net::RecvAncillaryBuffer;
|
||||
use rustix::net::RecvAncillaryMessage;
|
||||
use rustix::net::RecvFlags;
|
||||
use rustix::net::ReturnFlags;
|
||||
use std::io::IoSliceMut;
|
||||
|
||||
let mut byte = [0_u8];
|
||||
let mut buffers = [IoSliceMut::new(&mut byte)];
|
||||
let mut space = [std::mem::MaybeUninit::uninit(); rustix::cmsg_space!(ScmRights(1))];
|
||||
let mut control = RecvAncillaryBuffer::new(&mut space);
|
||||
// Linux can set close-on-exec while receiving the fd.
|
||||
#[cfg(target_os = "linux")]
|
||||
let flags = RecvFlags::CMSG_CLOEXEC;
|
||||
// Other Unix platforms need the non-atomic fcntl call below.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
let flags = RecvFlags::empty();
|
||||
let message = rustix::net::recvmsg(socket, &mut buffers, &mut control, flags)?;
|
||||
if message.bytes != 1 || message.flags.contains(ReturnFlags::CTRUNC) {
|
||||
return Err(io::Error::other("invalid file-descriptor control message"));
|
||||
}
|
||||
let descriptor = control
|
||||
.drain()
|
||||
.find_map(|message| match message {
|
||||
RecvAncillaryMessage::ScmRights(mut descriptors) => descriptors.next(),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| io::Error::other("missing transferred file descriptor"))?;
|
||||
// macOS cannot set this atomically, so the fd is briefly inheritable.
|
||||
// Shell and filesystem helper launches close inherited fds to limit that race.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
rustix::io::fcntl_setfd(&descriptor, rustix::io::FdFlags::CLOEXEC)?;
|
||||
Ok(descriptor)
|
||||
}
|
||||
|
||||
// Windows file handles must be duplicated across processes.
|
||||
#[cfg(windows)]
|
||||
fn duplicate_file_handle(process_id: u32, file_handle: u64) -> io::Result<std::fs::File> {
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use std::os::windows::io::FromRawHandle;
|
||||
use std::os::windows::io::OwnedHandle;
|
||||
use windows_sys::Win32::Foundation::DUPLICATE_SAME_ACCESS;
|
||||
use windows_sys::Win32::Foundation::DuplicateHandle;
|
||||
use windows_sys::Win32::Foundation::HANDLE;
|
||||
use windows_sys::Win32::System::Threading::GetCurrentProcess;
|
||||
use windows_sys::Win32::System::Threading::OpenProcess;
|
||||
use windows_sys::Win32::System::Threading::PROCESS_DUP_HANDLE;
|
||||
|
||||
// SAFETY: OpenProcess returns an owned handle or null on failure.
|
||||
let process = unsafe { OpenProcess(PROCESS_DUP_HANDLE, 0, process_id) };
|
||||
if process == 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: The successful OpenProcess result is owned by this scope.
|
||||
let process = unsafe { OwnedHandle::from_raw_handle(process as _) };
|
||||
let mut duplicated: HANDLE = 0;
|
||||
// SAFETY: Both process handles remain valid and duplicated receives an owned file handle.
|
||||
if unsafe {
|
||||
DuplicateHandle(
|
||||
process.as_raw_handle() as HANDLE,
|
||||
file_handle as HANDLE,
|
||||
GetCurrentProcess(),
|
||||
&raw mut duplicated,
|
||||
0,
|
||||
0,
|
||||
DUPLICATE_SAME_ACCESS,
|
||||
)
|
||||
} == 0
|
||||
{
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// SAFETY: DuplicateHandle transferred ownership of the new file handle.
|
||||
Ok(unsafe { std::fs::File::from_raw_handle(duplicated as _) })
|
||||
}
|
||||
@@ -3,12 +3,14 @@ use base64::engine::general_purpose::STANDARD;
|
||||
use codex_exec_server_protocol::JSONRPCErrorError;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use tokio::io;
|
||||
use tokio_util::io::ReaderStream;
|
||||
|
||||
use crate::CopyOptions;
|
||||
use crate::CreateDirectoryOptions;
|
||||
use crate::ExecServerRuntimePaths;
|
||||
use crate::ExecutorFileSystem;
|
||||
use crate::ExecutorFileSystemFuture;
|
||||
use crate::FILE_READ_CHUNK_SIZE;
|
||||
use crate::FileMetadata;
|
||||
use crate::FileSystemReadStream;
|
||||
use crate::FileSystemResult;
|
||||
@@ -36,6 +38,22 @@ pub struct SandboxedFileSystem {
|
||||
}
|
||||
|
||||
impl SandboxedFileSystem {
|
||||
pub(crate) async fn open_file_for_read(
|
||||
&self,
|
||||
path: &PathUri,
|
||||
sandbox: Option<&FileSystemSandboxContext>,
|
||||
) -> FileSystemResult<tokio::fs::File> {
|
||||
let sandbox = require_platform_sandbox(sandbox)?;
|
||||
validate_native_path(path)?;
|
||||
let command = self
|
||||
.sandbox_runner
|
||||
.sandbox_command(sandbox)
|
||||
.map_err(map_sandbox_error)?;
|
||||
crate::sandboxed_file_open::open(command, path.clone())
|
||||
.await
|
||||
.map_err(map_sandbox_error)
|
||||
}
|
||||
|
||||
pub fn new(runtime_paths: ExecServerRuntimePaths) -> Self {
|
||||
Self {
|
||||
sandbox_runner: FileSystemSandboxRunner::new(runtime_paths),
|
||||
@@ -295,14 +313,15 @@ impl ExecutorFileSystem for SandboxedFileSystem {
|
||||
|
||||
fn read_file_stream<'a>(
|
||||
&'a self,
|
||||
_path: &'a PathUri,
|
||||
_sandbox: Option<&'a FileSystemSandboxContext>,
|
||||
path: &'a PathUri,
|
||||
sandbox: Option<&'a FileSystemSandboxContext>,
|
||||
) -> ExecutorFileSystemFuture<'a, FileSystemReadStream> {
|
||||
Box::pin(async {
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::Unsupported,
|
||||
"streaming file reads do not support platform sandboxing",
|
||||
))
|
||||
Box::pin(async move {
|
||||
let file = self.open_file_for_read(path, sandbox).await?;
|
||||
Ok(FileSystemReadStream::new(ReaderStream::with_capacity(
|
||||
file,
|
||||
FILE_READ_CHUNK_SIZE,
|
||||
)))
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ use codex_exec_server::Environment;
|
||||
use codex_exec_server::ExecServerClient;
|
||||
use codex_exec_server::ExecServerError;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
use codex_exec_server::FsCloseParams;
|
||||
use codex_exec_server::FsOpenParams;
|
||||
use codex_exec_server::FsReadBlockParams;
|
||||
@@ -13,13 +12,6 @@ use codex_exec_server::FsReadBlockResponse;
|
||||
use codex_exec_server::RemoteExecServerConnectArgs;
|
||||
use codex_http_client::HttpClientFactory;
|
||||
use codex_http_client::OutboundProxyPolicy;
|
||||
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_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_path_uri::PathUri;
|
||||
use futures::TryStreamExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -83,32 +75,6 @@ async fn completed_streams_release_handle_capacity() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_rejects_platform_sandbox() -> Result<()> {
|
||||
let server = exec_server().await?;
|
||||
let file_system = connect_file_system(server.websocket_url())?;
|
||||
let tmp = TempDir::new()?;
|
||||
let path = tmp.path().join("sandboxed.txt");
|
||||
std::fs::write(&path, "sandboxed hello")?;
|
||||
|
||||
let result = file_system
|
||||
.read_file_stream(
|
||||
&PathUri::from_host_native_path(&path)?,
|
||||
Some(&read_only_sandbox(tmp.path().to_path_buf())),
|
||||
)
|
||||
.await;
|
||||
|
||||
let Err(error) = result else {
|
||||
panic!("sandboxed stream should be rejected");
|
||||
};
|
||||
assert_eq!(error.kind(), std::io::ErrorKind::Unsupported);
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
"streaming file reads do not support platform sandboxing"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
async fn file_reads_reject_fifo_without_waiting_for_a_writer() -> Result<()> {
|
||||
@@ -206,11 +172,9 @@ async fn stream_keeps_reading_the_open_file_after_path_replacement() -> Result<(
|
||||
let tmp = TempDir::new()?;
|
||||
let path = tmp.path().join("replaceable.bin");
|
||||
std::fs::write(&path, vec![b'a'; BLOCK_SIZE + 1])?;
|
||||
let sandbox = read_only_sandbox(tmp.path().to_path_buf());
|
||||
let mut stream = file_system
|
||||
.read_file_stream(
|
||||
&PathUri::from_host_native_path(&path)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.read_file_stream(&PathUri::from_host_native_path(&path)?, Some(&sandbox))
|
||||
.await?;
|
||||
|
||||
assert_eq!(
|
||||
@@ -394,7 +358,18 @@ fn connect_file_system(websocket_url: &str) -> Result<Arc<dyn ExecutorFileSystem
|
||||
Ok(environment.get_filesystem())
|
||||
}
|
||||
|
||||
fn read_only_sandbox(path: std::path::PathBuf) -> FileSystemSandboxContext {
|
||||
// Only the Unix stream tests above need this sandbox builder.
|
||||
#[cfg(unix)]
|
||||
fn read_only_sandbox(path: std::path::PathBuf) -> codex_exec_server::FileSystemSandboxContext {
|
||||
use codex_exec_server::FileSystemSandboxContext;
|
||||
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_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
let path = AbsolutePathBuf::from_absolute_path(&path)
|
||||
.unwrap_or_else(|err| panic!("sandbox path should be absolute: {err}"));
|
||||
FileSystemSandboxContext::from_permission_profile(PermissionProfile::from_runtime_permissions(
|
||||
|
||||
@@ -225,28 +225,29 @@ async fn file_system_read_file_stream_returns_bounded_chunks(
|
||||
.collect::<Vec<_>>();
|
||||
std::fs::write(&file_path, &contents)?;
|
||||
|
||||
let chunks = file_system
|
||||
.read_file_stream(
|
||||
&PathUri::from_host_native_path(file_path)?,
|
||||
/*sandbox*/ None,
|
||||
)
|
||||
.await
|
||||
.with_context(|| format!("mode={implementation}"))?
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
let path = PathUri::from_host_native_path(file_path)?;
|
||||
let sandbox = read_only_sandbox(tmp.path().to_path_buf());
|
||||
for sandbox in [None, Some(&sandbox)] {
|
||||
let chunks = file_system
|
||||
.read_file_stream(&path, sandbox)
|
||||
.await
|
||||
.with_context(|| format!("mode={implementation}"))?
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
|
||||
assert!(
|
||||
chunks
|
||||
.iter()
|
||||
.all(|chunk| !chunk.is_empty() && chunk.len() <= FILE_READ_CHUNK_SIZE)
|
||||
);
|
||||
assert_eq!(
|
||||
chunks
|
||||
.iter()
|
||||
.flat_map(|chunk| chunk.iter().copied())
|
||||
.collect::<Vec<_>>(),
|
||||
contents
|
||||
);
|
||||
assert!(
|
||||
chunks
|
||||
.iter()
|
||||
.all(|chunk| !chunk.is_empty() && chunk.len() <= FILE_READ_CHUNK_SIZE)
|
||||
);
|
||||
assert_eq!(
|
||||
chunks
|
||||
.iter()
|
||||
.flat_map(|chunk| chunk.iter().copied())
|
||||
.collect::<Vec<_>>(),
|
||||
contents
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -691,6 +692,14 @@ async fn file_system_sandboxed_metadata_and_read_allow_readable_root(
|
||||
.with_context(|| format!("mode={implementation}"))?;
|
||||
assert_eq!(contents, b"sandboxed hello");
|
||||
|
||||
let chunks = file_system
|
||||
.read_file_stream(&PathUri::from_host_native_path(&file_path)?, Some(&sandbox))
|
||||
.await
|
||||
.with_context(|| format!("stream mode={implementation}"))?
|
||||
.try_collect::<Vec<_>>()
|
||||
.await?;
|
||||
assert_eq!(chunks.concat(), b"sandboxed hello");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -630,6 +630,16 @@ async fn file_system_sandboxed_read_rejects_symlink_escape(
|
||||
};
|
||||
assert_sandbox_denied(&error);
|
||||
|
||||
let error = file_system
|
||||
.read_file_stream(
|
||||
&PathUri::from_host_native_path(&requested_path)?,
|
||||
Some(&sandbox),
|
||||
)
|
||||
.await
|
||||
.err()
|
||||
.context("streaming read should be blocked")?;
|
||||
assert_sandbox_denied(&error);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -467,7 +467,78 @@ fn set_cloexec(fd: RawFd) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
// macOS needs a fork-safe sweep because recvmsg cannot set close-on-exec.
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn close_inherited_fds_except(preserved_fds: &[RawFd]) {
|
||||
let mut descriptors = [libc::proc_fdinfo {
|
||||
proc_fd: 0,
|
||||
proc_fdtype: 0,
|
||||
}; 1024];
|
||||
// SAFETY: proc_pidinfo writes descriptor records into the stack buffer.
|
||||
let bytes = unsafe {
|
||||
libc::proc_pidinfo(
|
||||
libc::getpid(),
|
||||
libc::PROC_PIDLISTFDS,
|
||||
/*arg*/ 0,
|
||||
descriptors.as_mut_ptr().cast(),
|
||||
std::mem::size_of_val(&descriptors) as libc::c_int,
|
||||
)
|
||||
};
|
||||
let close_inheritable = |fd| {
|
||||
if fd <= libc::STDERR_FILENO || preserved_fds.contains(&fd) {
|
||||
return;
|
||||
}
|
||||
// std::process keeps a CLOEXEC pipe open until exec to report spawn errors.
|
||||
// SAFETY: fcntl and close only operate on a descriptor owned by this process.
|
||||
unsafe {
|
||||
let flags = libc::fcntl(fd, libc::F_GETFD);
|
||||
if flags >= 0 && flags & libc::FD_CLOEXEC == 0 {
|
||||
libc::close(fd);
|
||||
}
|
||||
}
|
||||
};
|
||||
if bytes > 0 && (bytes as usize) < std::mem::size_of_val(&descriptors) {
|
||||
let count = bytes as usize / std::mem::size_of::<libc::proc_fdinfo>();
|
||||
for descriptor in descriptors.iter().take(count) {
|
||||
close_inheritable(descriptor.proc_fd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// SAFETY: proc_pidinfo accepts a null buffer when its size is zero.
|
||||
let descriptor_table_bytes = unsafe {
|
||||
libc::proc_pidinfo(
|
||||
libc::getpid(),
|
||||
libc::PROC_PIDLISTFDS,
|
||||
/*arg*/ 0,
|
||||
std::ptr::null_mut(),
|
||||
/*buffersize*/ 0,
|
||||
)
|
||||
};
|
||||
if descriptor_table_bytes > 0 {
|
||||
let upper_bound =
|
||||
descriptor_table_bytes as usize / std::mem::size_of::<libc::proc_fdinfo>();
|
||||
for fd in libc::STDERR_FILENO + 1..upper_bound as RawFd {
|
||||
close_inheritable(fd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let mut limit = libc::rlimit {
|
||||
rlim_cur: 0,
|
||||
rlim_max: 0,
|
||||
};
|
||||
// SAFETY: getrlimit writes into the stack-owned resource-limit structure.
|
||||
if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &raw mut limit) } == 0 {
|
||||
let upper_bound = limit.rlim_cur.min(RawFd::MAX as _) as RawFd;
|
||||
for fd in libc::STDERR_FILENO + 1..upper_bound {
|
||||
close_inheritable(fd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Other Unix platforms keep their existing fd cleanup.
|
||||
#[cfg(all(unix, not(target_os = "macos")))]
|
||||
pub(crate) fn close_inherited_fds_except(preserved_fds: &[RawFd]) {
|
||||
if let Ok(dir) = std::fs::read_dir("/dev/fd") {
|
||||
let mut fds = Vec::new();
|
||||
|
||||
Reference in New Issue
Block a user