mirror of
https://github.com/openai/codex.git
synced 2026-11-01 19:33:35 +00:00
Merge 1ade38bc7b into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -119,6 +119,7 @@ impl<T: HttpTransport, A: AuthProvider> RealtimeCallClient<T, A> {
|
||||
session_config: RealtimeSessionConfig,
|
||||
extra_headers: HeaderMap,
|
||||
) -> Result<RealtimeCallResponse, ApiError> {
|
||||
trace!(target: "codex_api::realtime_websocket::wire", "realtime call request SDP: {sdp}");
|
||||
// WebRTC can begin inference as soon as the peer connection comes up, so the initial
|
||||
// session payload is sent with call creation. The sideband WebSocket still sends its normal
|
||||
// session.update after it joins.
|
||||
|
||||
@@ -45,6 +45,8 @@ use tracing::warn;
|
||||
use tungstenite::protocol::WebSocketConfig;
|
||||
use url::Url;
|
||||
|
||||
const REALTIME_WIRE_LOG_TARGET: &str = "codex_api::realtime_websocket::wire";
|
||||
|
||||
struct WsStream {
|
||||
tx_command: mpsc::Sender<WsCommand>,
|
||||
pump_task: tokio::task::JoinHandle<()>,
|
||||
@@ -351,6 +353,7 @@ impl RealtimeWebsocketWriter {
|
||||
));
|
||||
}
|
||||
|
||||
trace!(target: REALTIME_WIRE_LOG_TARGET, "realtime websocket request: {payload}");
|
||||
self.stream
|
||||
.send(Message::Text(payload.into()))
|
||||
.await
|
||||
@@ -384,6 +387,7 @@ impl RealtimeWebsocketEvents {
|
||||
|
||||
match msg {
|
||||
Message::Text(text) => {
|
||||
trace!(target: REALTIME_WIRE_LOG_TARGET, "realtime websocket event: {text}");
|
||||
if let Some(mut event) = parse_realtime_event(&text, self.event_parser) {
|
||||
self.update_active_transcript(&mut event).await;
|
||||
debug!(?event, "realtime websocket parsed event");
|
||||
|
||||
@@ -139,7 +139,6 @@ pub use project_doc::discover_project_doc_paths;
|
||||
pub use project_doc::read_project_docs;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
pub mod seatbelt;
|
||||
mod session_rollout_init_error;
|
||||
pub mod shell;
|
||||
pub(crate) mod shell_snapshot;
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use crate::spawn::SpawnChildRequest;
|
||||
use crate::spawn::StdioPolicy;
|
||||
use crate::spawn::spawn_child_async;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::process::Child;
|
||||
|
||||
pub async fn spawn_command_under_seatbelt(
|
||||
command: Vec<String>,
|
||||
command_cwd: AbsolutePathBuf,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
sandbox_policy_cwd: &AbsolutePathBuf,
|
||||
stdio_policy: StdioPolicy,
|
||||
network: Option<&NetworkProxy>,
|
||||
mut env: HashMap<String, String>,
|
||||
) -> std::io::Result<Child> {
|
||||
let args = create_seatbelt_command_args_for_policies(
|
||||
command,
|
||||
&FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd),
|
||||
NetworkSandboxPolicy::from(sandbox_policy),
|
||||
sandbox_policy_cwd,
|
||||
/*enforce_managed_network*/ false,
|
||||
network,
|
||||
);
|
||||
let arg0 = None;
|
||||
env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
|
||||
spawn_child_async(SpawnChildRequest {
|
||||
program: PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE),
|
||||
args,
|
||||
arg0,
|
||||
cwd: command_cwd,
|
||||
network_sandbox_policy: NetworkSandboxPolicy::from(sandbox_policy),
|
||||
network,
|
||||
stdio_policy,
|
||||
env,
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -14,6 +14,9 @@ use codex_tools::TOOL_SEARCH_TOOL_NAME;
|
||||
use codex_tools::ToolSearchResultSource;
|
||||
use codex_tools::collect_tool_search_output_tools;
|
||||
|
||||
const COMPUTER_USE_MCP_SERVER_NAME: &str = "computer-use";
|
||||
const COMPUTER_USE_TOOL_SEARCH_LIMIT: usize = 20;
|
||||
|
||||
pub struct ToolSearchHandler {
|
||||
entries: Vec<(String, ToolInfo)>,
|
||||
search_engine: SearchEngine<usize>,
|
||||
@@ -67,7 +70,8 @@ impl ToolHandler for ToolSearchHandler {
|
||||
"query must not be empty".to_string(),
|
||||
));
|
||||
}
|
||||
let limit = args.limit.unwrap_or(TOOL_SEARCH_DEFAULT_LIMIT);
|
||||
let requested_limit = args.limit;
|
||||
let limit = requested_limit.unwrap_or(TOOL_SEARCH_DEFAULT_LIMIT);
|
||||
|
||||
if limit == 0 {
|
||||
return Err(FunctionCallError::RespondToModel(
|
||||
@@ -79,21 +83,18 @@ impl ToolHandler for ToolSearchHandler {
|
||||
return Ok(ToolSearchOutput { tools: Vec::new() });
|
||||
}
|
||||
|
||||
let results = self.search_engine.search(query, limit);
|
||||
let results = self.search_result_entries(query, limit, requested_limit.is_none());
|
||||
|
||||
let tools = collect_tool_search_output_tools(
|
||||
results
|
||||
.into_iter()
|
||||
.filter_map(|result| self.entries.get(result.document.id))
|
||||
.map(|(_, tool)| ToolSearchResultSource {
|
||||
server_name: tool.server_name.as_str(),
|
||||
tool_namespace: tool.callable_namespace.as_str(),
|
||||
tool_name: tool.callable_name.as_str(),
|
||||
tool: &tool.tool,
|
||||
connector_name: tool.connector_name.as_deref(),
|
||||
connector_description: tool.connector_description.as_deref(),
|
||||
}),
|
||||
)
|
||||
let tools = collect_tool_search_output_tools(results.into_iter().map(|(_, tool)| {
|
||||
ToolSearchResultSource {
|
||||
server_name: tool.server_name.as_str(),
|
||||
tool_namespace: tool.callable_namespace.as_str(),
|
||||
tool_name: tool.callable_name.as_str(),
|
||||
tool: &tool.tool,
|
||||
connector_name: tool.connector_name.as_deref(),
|
||||
connector_description: tool.connector_description.as_deref(),
|
||||
}
|
||||
}))
|
||||
.map_err(|err| {
|
||||
FunctionCallError::Fatal(format!(
|
||||
"failed to encode {TOOL_SEARCH_TOOL_NAME} output: {err}"
|
||||
@@ -104,6 +105,66 @@ impl ToolHandler for ToolSearchHandler {
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolSearchHandler {
|
||||
fn search_result_entries(
|
||||
&self,
|
||||
query: &str,
|
||||
limit: usize,
|
||||
use_default_limit: bool,
|
||||
) -> Vec<&(String, ToolInfo)> {
|
||||
let mut results = self
|
||||
.search_engine
|
||||
.search(query, limit)
|
||||
.into_iter()
|
||||
.filter_map(|result| self.entries.get(result.document.id))
|
||||
.collect::<Vec<_>>();
|
||||
if !use_default_limit {
|
||||
return results;
|
||||
}
|
||||
|
||||
if results
|
||||
.iter()
|
||||
.any(|(_, tool)| tool.server_name == COMPUTER_USE_MCP_SERVER_NAME)
|
||||
{
|
||||
results = self
|
||||
.search_engine
|
||||
.search(query, COMPUTER_USE_TOOL_SEARCH_LIMIT)
|
||||
.into_iter()
|
||||
.filter_map(|result| self.entries.get(result.document.id))
|
||||
.collect();
|
||||
}
|
||||
limit_results_per_server(results)
|
||||
}
|
||||
}
|
||||
|
||||
fn limit_results_per_server(results: Vec<&(String, ToolInfo)>) -> Vec<&(String, ToolInfo)> {
|
||||
results
|
||||
.into_iter()
|
||||
.scan(
|
||||
std::collections::HashMap::<&str, usize>::new(),
|
||||
|counts, entry| {
|
||||
let tool = &entry.1;
|
||||
let count = counts.entry(tool.server_name.as_str()).or_default();
|
||||
if *count >= default_limit_for_server(tool.server_name.as_str()) {
|
||||
Some(None)
|
||||
} else {
|
||||
*count += 1;
|
||||
Some(Some(entry))
|
||||
}
|
||||
},
|
||||
)
|
||||
.flatten()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_limit_for_server(server_name: &str) -> usize {
|
||||
if server_name == COMPUTER_USE_MCP_SERVER_NAME {
|
||||
COMPUTER_USE_TOOL_SEARCH_LIMIT
|
||||
} else {
|
||||
TOOL_SEARCH_DEFAULT_LIMIT
|
||||
}
|
||||
}
|
||||
|
||||
fn build_search_text(name: &str, info: &ToolInfo) -> String {
|
||||
let mut parts = vec![
|
||||
name.to_string(),
|
||||
@@ -156,3 +217,153 @@ fn build_search_text(name: &str, info: &ToolInfo) -> String {
|
||||
|
||||
parts.join(" ")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use rmcp::model::Tool;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[test]
|
||||
fn computer_use_tool_search_uses_larger_limit() {
|
||||
let handler = ToolSearchHandler::new(numbered_tools(
|
||||
COMPUTER_USE_MCP_SERVER_NAME,
|
||||
"computer use",
|
||||
/*count*/ 100,
|
||||
));
|
||||
|
||||
let results = handler.search_result_entries(
|
||||
"computer use",
|
||||
TOOL_SEARCH_DEFAULT_LIMIT,
|
||||
/*use_default_limit*/ true,
|
||||
);
|
||||
|
||||
assert_eq!(results.len(), COMPUTER_USE_TOOL_SEARCH_LIMIT);
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.all(|(_, tool)| tool.server_name == COMPUTER_USE_MCP_SERVER_NAME)
|
||||
);
|
||||
|
||||
let explicit_results = handler.search_result_entries(
|
||||
"computer use",
|
||||
/*limit*/ 100,
|
||||
/*use_default_limit*/ false,
|
||||
);
|
||||
|
||||
assert_eq!(explicit_results.len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_computer_use_query_keeps_default_limit_with_computer_use_tools_installed() {
|
||||
let mut tools = numbered_tools(
|
||||
COMPUTER_USE_MCP_SERVER_NAME,
|
||||
"computer use",
|
||||
/*count*/ 100,
|
||||
);
|
||||
tools.extend(numbered_tools(
|
||||
"other-server",
|
||||
"calendar",
|
||||
/*count*/ 100,
|
||||
));
|
||||
let handler = ToolSearchHandler::new(tools);
|
||||
|
||||
let results = handler.search_result_entries(
|
||||
"calendar",
|
||||
TOOL_SEARCH_DEFAULT_LIMIT,
|
||||
/*use_default_limit*/ true,
|
||||
);
|
||||
|
||||
assert_eq!(results.len(), TOOL_SEARCH_DEFAULT_LIMIT);
|
||||
assert!(
|
||||
results
|
||||
.iter()
|
||||
.all(|(_, tool)| tool.server_name == "other-server")
|
||||
);
|
||||
|
||||
let explicit_results = handler.search_result_entries(
|
||||
"calendar", /*limit*/ 100, /*use_default_limit*/ false,
|
||||
);
|
||||
|
||||
assert_eq!(explicit_results.len(), 100);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expanded_search_keeps_non_computer_use_servers_at_default_limit() {
|
||||
let mut tools = numbered_tools(
|
||||
COMPUTER_USE_MCP_SERVER_NAME,
|
||||
"computer use",
|
||||
/*count*/ 100,
|
||||
);
|
||||
tools.extend(numbered_tools(
|
||||
"other-server",
|
||||
"computer use",
|
||||
/*count*/ 100,
|
||||
));
|
||||
let handler = ToolSearchHandler::new(tools);
|
||||
|
||||
let results = handler.search_result_entries(
|
||||
"computer use",
|
||||
TOOL_SEARCH_DEFAULT_LIMIT,
|
||||
/*use_default_limit*/ true,
|
||||
);
|
||||
|
||||
assert!(
|
||||
count_results_for_server(&results, COMPUTER_USE_MCP_SERVER_NAME)
|
||||
<= COMPUTER_USE_TOOL_SEARCH_LIMIT
|
||||
);
|
||||
assert!(count_results_for_server(&results, "other-server") <= TOOL_SEARCH_DEFAULT_LIMIT);
|
||||
}
|
||||
|
||||
fn numbered_tools(
|
||||
server_name: &str,
|
||||
description_prefix: &str,
|
||||
count: usize,
|
||||
) -> std::collections::HashMap<String, ToolInfo> {
|
||||
(0..count)
|
||||
.map(|index| {
|
||||
let tool_name = format!("tool_{index:03}");
|
||||
(
|
||||
format!("mcp__{server_name}__{tool_name}"),
|
||||
tool_info(server_name, &tool_name, description_prefix),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tool_info(server_name: &str, tool_name: &str, description_prefix: &str) -> ToolInfo {
|
||||
ToolInfo {
|
||||
server_name: server_name.to_string(),
|
||||
callable_name: tool_name.to_string(),
|
||||
callable_namespace: format!("mcp__{server_name}__"),
|
||||
server_instructions: None,
|
||||
tool: Tool {
|
||||
name: tool_name.to_string().into(),
|
||||
title: None,
|
||||
description: Some(format!("{description_prefix} desktop tool").into()),
|
||||
input_schema: Arc::new(rmcp::model::object(serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"additionalProperties": false,
|
||||
}))),
|
||||
output_schema: None,
|
||||
annotations: None,
|
||||
execution: None,
|
||||
icons: None,
|
||||
meta: None,
|
||||
},
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
plugin_display_names: Vec::new(),
|
||||
connector_description: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn count_results_for_server(results: &[&(String, ToolInfo)], server_name: &str) -> usize {
|
||||
results
|
||||
.iter()
|
||||
.filter(|(_, tool)| tool.server_name == server_name)
|
||||
.count()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,14 +29,14 @@ fn skip_test() -> bool {
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput> {
|
||||
async fn run_test_cmd(tmp: TempDir, command: Vec<String>) -> Result<ExecToolCallOutput> {
|
||||
let sandbox_type = get_platform_sandbox(/*windows_sandbox_enabled*/ false)
|
||||
.expect("should be able to get sandbox type");
|
||||
assert_eq!(sandbox_type, SandboxType::MacosSeatbelt);
|
||||
let cwd = tmp.path().abs();
|
||||
|
||||
let params = ExecParams {
|
||||
command: cmd.iter().map(ToString::to_string).collect(),
|
||||
command,
|
||||
cwd: cwd.clone(),
|
||||
expiration: 1000.into(),
|
||||
capture_policy: ExecCapturePolicy::ShellTool,
|
||||
@@ -64,6 +64,11 @@ async fn run_test_cmd(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput
|
||||
.await
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
async fn run_test_cmd_strs(tmp: TempDir, cmd: Vec<&str>) -> Result<ExecToolCallOutput> {
|
||||
run_test_cmd(tmp, cmd.iter().map(ToString::to_string).collect()).await
|
||||
}
|
||||
|
||||
/// Command succeeds with exit code 0 normally
|
||||
#[tokio::test]
|
||||
async fn exit_code_0_succeeds() {
|
||||
@@ -74,7 +79,7 @@ async fn exit_code_0_succeeds() {
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let cmd = vec!["echo", "hello"];
|
||||
|
||||
let output = run_test_cmd(tmp, cmd).await.unwrap();
|
||||
let output = run_test_cmd_strs(tmp, cmd).await.unwrap();
|
||||
assert_eq!(output.stdout.text, "hello\n");
|
||||
assert_eq!(output.stderr.text, "");
|
||||
assert_eq!(output.stdout.truncated_after_lines, None);
|
||||
@@ -90,7 +95,7 @@ async fn truncates_output_lines() {
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let cmd = vec!["seq", "300"];
|
||||
|
||||
let output = run_test_cmd(tmp, cmd).await.unwrap();
|
||||
let output = run_test_cmd_strs(tmp, cmd).await.unwrap();
|
||||
|
||||
let expected_output = (1..=300)
|
||||
.map(|i| format!("{i}\n"))
|
||||
@@ -111,7 +116,7 @@ async fn truncates_output_bytes() {
|
||||
// each line is 1000 bytes
|
||||
let cmd = vec!["bash", "-lc", "seq 15 | awk '{printf \"%-1000s\\n\", $0}'"];
|
||||
|
||||
let output = run_test_cmd(tmp, cmd).await.unwrap();
|
||||
let output = run_test_cmd_strs(tmp, cmd).await.unwrap();
|
||||
|
||||
assert!(output.stdout.text.len() >= 15000);
|
||||
assert_eq!(output.stdout.truncated_after_lines, None);
|
||||
@@ -126,7 +131,38 @@ async fn exit_command_not_found_is_ok() {
|
||||
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let cmd = vec!["/bin/bash", "-c", "nonexistent_command_12345"];
|
||||
run_test_cmd(tmp, cmd).await.unwrap();
|
||||
run_test_cmd_strs(tmp, cmd).await.unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openpty_works_under_real_exec_seatbelt_path() {
|
||||
if skip_test() {
|
||||
return;
|
||||
}
|
||||
|
||||
let python = match which::which("python3") {
|
||||
Ok(path) => path,
|
||||
Err(_) => {
|
||||
eprintln!("python3 not found in PATH, skipping test.");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let cmd = vec![
|
||||
python.to_string_lossy().into_owned(),
|
||||
"-c".to_string(),
|
||||
r#"import os
|
||||
|
||||
master, slave = os.openpty()
|
||||
os.write(slave, b"ping")
|
||||
assert os.read(master, 4) == b"ping""#
|
||||
.to_string(),
|
||||
];
|
||||
|
||||
let output = run_test_cmd(tmp, cmd).await.unwrap();
|
||||
assert_eq!(output.stdout.text, "");
|
||||
assert_eq!(output.stderr.text, "");
|
||||
}
|
||||
|
||||
/// Writing a file fails and should be considered a sandbox error
|
||||
@@ -139,9 +175,9 @@ async fn write_file_fails_as_sandbox_error() {
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let path = tmp.path().join("test.txt");
|
||||
let cmd = vec![
|
||||
"/user/bin/touch",
|
||||
"/usr/bin/touch",
|
||||
path.to_str().expect("should be able to get path"),
|
||||
];
|
||||
|
||||
assert!(run_test_cmd(tmp, cmd).await.is_err());
|
||||
assert!(run_test_cmd_strs(tmp, cmd).await.is_err());
|
||||
}
|
||||
|
||||
@@ -85,7 +85,6 @@ mod rmcp_client;
|
||||
mod rollout_list_find;
|
||||
mod safety_check_downgrade;
|
||||
mod search_tool;
|
||||
mod seatbelt;
|
||||
mod shell_command;
|
||||
mod shell_serialization;
|
||||
mod shell_snapshot;
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
//! Tests for the macOS sandboxing that are specific to Seatbelt.
|
||||
//! Tests that apply to both Mac and Linux sandboxing should go in sandbox.rs.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_core::seatbelt::spawn_command_under_seatbelt;
|
||||
use codex_core::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use codex_core::spawn::StdioPolicy;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
struct TestScenario {
|
||||
repo_parent: PathBuf,
|
||||
file_outside_repo: PathBuf,
|
||||
repo_root: PathBuf,
|
||||
file_in_repo_root: PathBuf,
|
||||
file_in_dot_git_dir: PathBuf,
|
||||
}
|
||||
|
||||
struct TestExpectations {
|
||||
file_outside_repo_is_writable: bool,
|
||||
file_in_repo_root_is_writable: bool,
|
||||
file_in_dot_git_dir_is_writable: bool,
|
||||
}
|
||||
|
||||
impl TestScenario {
|
||||
async fn run_test(&self, policy: &SandboxPolicy, expectations: TestExpectations) {
|
||||
if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) {
|
||||
eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test.");
|
||||
return;
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
touch(&self.file_outside_repo, policy).await,
|
||||
expectations.file_outside_repo_is_writable
|
||||
);
|
||||
assert_eq!(
|
||||
self.file_outside_repo.exists(),
|
||||
expectations.file_outside_repo_is_writable
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
touch(&self.file_in_repo_root, policy).await,
|
||||
expectations.file_in_repo_root_is_writable
|
||||
);
|
||||
assert_eq!(
|
||||
self.file_in_repo_root.exists(),
|
||||
expectations.file_in_repo_root_is_writable
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
touch(&self.file_in_dot_git_dir, policy).await,
|
||||
expectations.file_in_dot_git_dir_is_writable
|
||||
);
|
||||
assert_eq!(
|
||||
self.file_in_dot_git_dir.exists(),
|
||||
expectations.file_in_dot_git_dir_is_writable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// If the user has added a workspace root that is not a Git repo root, then
|
||||
/// the user has to specify `--skip-git-repo-check` or go through some
|
||||
/// interstitial that indicates they are taking on some risk because Git
|
||||
/// cannot be used to backup their work before the agent begins.
|
||||
///
|
||||
/// Because the user has agreed to this risk, we do not try find all .git
|
||||
/// folders in the workspace and block them (though we could change our
|
||||
/// position on this in the future).
|
||||
#[tokio::test]
|
||||
async fn if_parent_of_repo_is_writable_then_dot_git_folder_is_writable() {
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let test_scenario = create_test_scenario(&tmp);
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![test_scenario.repo_parent.as_path().try_into().unwrap()],
|
||||
read_only_access: Default::default(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
test_scenario
|
||||
.run_test(
|
||||
&policy,
|
||||
TestExpectations {
|
||||
file_outside_repo_is_writable: true,
|
||||
file_in_repo_root_is_writable: true,
|
||||
file_in_dot_git_dir_is_writable: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// When the writable root is the root of a Git repository (as evidenced by the
|
||||
/// presence of a .git folder), then the .git folder should be read-only if
|
||||
/// the policy is `WorkspaceWrite`.
|
||||
#[tokio::test]
|
||||
async fn if_git_repo_is_writable_root_then_dot_git_folder_is_read_only() {
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let test_scenario = create_test_scenario(&tmp);
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![test_scenario.repo_root.as_path().try_into().unwrap()],
|
||||
read_only_access: Default::default(),
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
|
||||
test_scenario
|
||||
.run_test(
|
||||
&policy,
|
||||
TestExpectations {
|
||||
file_outside_repo_is_writable: false,
|
||||
file_in_repo_root_is_writable: true,
|
||||
file_in_dot_git_dir_is_writable: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Under DangerFullAccess, all writes should be permitted anywhere on disk,
|
||||
/// including inside the .git folder.
|
||||
#[tokio::test]
|
||||
async fn danger_full_access_allows_all_writes() {
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let test_scenario = create_test_scenario(&tmp);
|
||||
let policy = SandboxPolicy::DangerFullAccess;
|
||||
|
||||
test_scenario
|
||||
.run_test(
|
||||
&policy,
|
||||
TestExpectations {
|
||||
file_outside_repo_is_writable: true,
|
||||
file_in_repo_root_is_writable: true,
|
||||
file_in_dot_git_dir_is_writable: true,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Under ReadOnly, writes should not be permitted anywhere on disk.
|
||||
#[tokio::test]
|
||||
async fn read_only_forbids_all_writes() {
|
||||
let tmp = TempDir::new().expect("should be able to create temp dir");
|
||||
let test_scenario = create_test_scenario(&tmp);
|
||||
let policy = SandboxPolicy::new_read_only_policy();
|
||||
|
||||
test_scenario
|
||||
.run_test(
|
||||
&policy,
|
||||
TestExpectations {
|
||||
file_outside_repo_is_writable: false,
|
||||
file_in_repo_root_is_writable: false,
|
||||
file_in_dot_git_dir_is_writable: false,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn openpty_works_under_seatbelt() {
|
||||
if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) {
|
||||
eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test.");
|
||||
return;
|
||||
}
|
||||
|
||||
if which::which("python3").is_err() {
|
||||
eprintln!("python3 not found in PATH, skipping test.");
|
||||
return;
|
||||
}
|
||||
|
||||
let policy = SandboxPolicy::new_read_only_policy();
|
||||
let command_cwd = AbsolutePathBuf::current_dir().expect("getcwd");
|
||||
let sandbox_cwd = command_cwd.clone();
|
||||
|
||||
let mut child = spawn_command_under_seatbelt(
|
||||
vec![
|
||||
"python3".to_string(),
|
||||
"-c".to_string(),
|
||||
r#"import os
|
||||
|
||||
master, slave = os.openpty()
|
||||
os.write(slave, b"ping")
|
||||
assert os.read(master, 4) == b"ping""#
|
||||
.to_string(),
|
||||
],
|
||||
command_cwd,
|
||||
&policy,
|
||||
&sandbox_cwd,
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
/*network*/ None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("should be able to spawn python under seatbelt");
|
||||
|
||||
let status = child
|
||||
.wait()
|
||||
.await
|
||||
.expect("should be able to wait for child process");
|
||||
assert!(status.success(), "python exited with {status:?}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn java_home_finds_runtime_under_seatbelt() {
|
||||
if std::env::var(CODEX_SANDBOX_ENV_VAR) == Ok("seatbelt".to_string()) {
|
||||
eprintln!("{CODEX_SANDBOX_ENV_VAR} is set to 'seatbelt', skipping test.");
|
||||
return;
|
||||
}
|
||||
|
||||
let java_home_path = Path::new("/usr/libexec/java_home");
|
||||
if !java_home_path.exists() {
|
||||
eprintln!("/usr/libexec/java_home is not present, skipping test.");
|
||||
return;
|
||||
}
|
||||
|
||||
let baseline_output = tokio::process::Command::new(java_home_path)
|
||||
.env_remove("JAVA_HOME")
|
||||
.output()
|
||||
.await
|
||||
.expect("should be able to invoke java_home outside seatbelt");
|
||||
if !baseline_output.status.success() {
|
||||
eprintln!(
|
||||
"java_home exited with {:?} outside seatbelt, skipping test",
|
||||
baseline_output.status
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let policy = SandboxPolicy::new_read_only_policy();
|
||||
let command_cwd = AbsolutePathBuf::current_dir().expect("getcwd");
|
||||
let sandbox_cwd = command_cwd.clone();
|
||||
|
||||
let mut env: HashMap<String, String> = std::env::vars().collect();
|
||||
env.remove("JAVA_HOME");
|
||||
env.remove(CODEX_SANDBOX_ENV_VAR);
|
||||
|
||||
let child = spawn_command_under_seatbelt(
|
||||
vec![java_home_path.to_string_lossy().to_string()],
|
||||
command_cwd,
|
||||
&policy,
|
||||
&sandbox_cwd,
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
/*network*/ None,
|
||||
env,
|
||||
)
|
||||
.await
|
||||
.expect("should be able to spawn java_home under seatbelt");
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.await
|
||||
.expect("should be able to wait for java_home child");
|
||||
assert!(
|
||||
output.status.success(),
|
||||
"java_home under seatbelt exited with {:?}, stderr: {}",
|
||||
output.status,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
assert!(
|
||||
!stdout.trim().is_empty(),
|
||||
"java_home stdout unexpectedly empty under seatbelt"
|
||||
);
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
fn create_test_scenario(tmp: &TempDir) -> TestScenario {
|
||||
let repo_parent = tmp.path().to_path_buf();
|
||||
let repo_root = repo_parent.join("repo");
|
||||
let dot_git_dir = repo_root.join(".git");
|
||||
|
||||
std::fs::create_dir(&repo_root).expect("should be able to create repo root");
|
||||
std::fs::create_dir(&dot_git_dir).expect("should be able to create .git dir");
|
||||
|
||||
TestScenario {
|
||||
file_outside_repo: repo_parent.join("outside.txt"),
|
||||
repo_parent,
|
||||
file_in_repo_root: repo_root.join("repo_file.txt"),
|
||||
repo_root,
|
||||
file_in_dot_git_dir: dot_git_dir.join("dot_git_file.txt"),
|
||||
}
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
/// Note that `path` must be absolute.
|
||||
async fn touch(path: &Path, policy: &SandboxPolicy) -> bool {
|
||||
assert!(path.is_absolute(), "Path must be absolute: {path:?}");
|
||||
let command_cwd = AbsolutePathBuf::current_dir().expect("getcwd");
|
||||
let sandbox_cwd = command_cwd.clone();
|
||||
let mut child = spawn_command_under_seatbelt(
|
||||
vec![
|
||||
"/usr/bin/touch".to_string(),
|
||||
path.to_string_lossy().to_string(),
|
||||
],
|
||||
command_cwd,
|
||||
policy,
|
||||
&sandbox_cwd,
|
||||
StdioPolicy::RedirectForShellTool,
|
||||
/*network*/ None,
|
||||
HashMap::new(),
|
||||
)
|
||||
.await
|
||||
.expect("should be able to spawn command under seatbelt");
|
||||
child
|
||||
.wait()
|
||||
.await
|
||||
.expect("should be able to wait for child process")
|
||||
.success()
|
||||
}
|
||||
Reference in New Issue
Block a user