feat(sandbox): append Seatbelt denials to unified exec

This commit is contained in:
Dylan Hurd
2026-06-11 19:46:06 -07:00
parent 484518f284
commit f847460584
19 changed files with 340 additions and 44 deletions
+11
View File
@@ -622,6 +622,10 @@ pub struct ToolsToml {
)]
pub web_search: Option<WebSearchToolConfig>,
pub experimental_request_user_input: Option<ExperimentalRequestUserInput>,
/// Runtime settings for the model-facing unified exec tools.
#[serde(default)]
pub unified_exec: Option<UnifiedExecToml>,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
@@ -631,6 +635,13 @@ pub struct ExperimentalRequestUserInput {
pub enabled: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
#[schemars(deny_unknown_fields)]
pub struct UnifiedExecToml {
/// On macOS, append seatbelt sandbox denials to sandboxed command output.
pub log_macos_seatbelt_denials: Option<bool>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum WebSearchToolConfigInput {
+19
View File
@@ -2758,6 +2758,15 @@
"experimental_request_user_input": {
"$ref": "#/definitions/ExperimentalRequestUserInput"
},
"unified_exec": {
"allOf": [
{
"$ref": "#/definitions/UnifiedExecToml"
}
],
"default": null,
"description": "Runtime settings for the model-facing unified exec tools."
},
"web_search": {
"allOf": [
{
@@ -4240,6 +4249,16 @@
},
"type": "object"
},
"UnifiedExecToml": {
"additionalProperties": false,
"properties": {
"log_macos_seatbelt_denials": {
"description": "On macOS, append seatbelt sandbox denials to sandboxed command output.",
"type": "boolean"
}
},
"type": "object"
},
"UriBasedFileOpener": {
"oneOf": [
{
+5
View File
@@ -345,6 +345,7 @@ web_search = true
Some(ToolsToml {
web_search: None,
experimental_request_user_input: None,
..Default::default()
})
);
}
@@ -364,6 +365,7 @@ web_search = false
Some(ToolsToml {
web_search: None,
experimental_request_user_input: None,
..Default::default()
})
);
}
@@ -382,6 +384,7 @@ fn tools_experimental_request_user_input_defaults_to_enabled() {
Some(ToolsToml {
web_search: None,
experimental_request_user_input: Some(ExperimentalRequestUserInput { enabled: true }),
..Default::default()
})
);
}
@@ -401,6 +404,7 @@ enabled = false
Some(ToolsToml {
web_search: None,
experimental_request_user_input: Some(ExperimentalRequestUserInput { enabled: false }),
..Default::default()
})
);
}
@@ -415,6 +419,7 @@ async fn load_config_resolves_experimental_request_user_input_enabled() -> std::
experimental_request_user_input: Some(ExperimentalRequestUserInput {
enabled: false,
}),
..Default::default()
}),
..ConfigToml::default()
},
+15
View File
@@ -990,6 +990,9 @@ pub struct Config {
/// If set to `true`, used only the experimental unified exec tool.
pub use_experimental_unified_exec_tool: bool,
/// Whether unified exec appends macOS Seatbelt denials to command output.
pub unified_exec_log_macos_seatbelt_denials: bool,
/// Maximum poll window for background terminal output (`write_stdin`), in milliseconds.
/// Default: `300000` (5 minutes).
pub background_terminal_max_timeout: u64,
@@ -2329,6 +2332,15 @@ fn resolve_code_mode_config(config_toml: &ConfigToml) -> CodeModeConfig {
}
}
fn resolve_unified_exec_log_macos_seatbelt_denials(config_toml: &ConfigToml) -> bool {
config_toml
.tools
.as_ref()
.and_then(|tools| tools.unified_exec.as_ref())
.and_then(|unified_exec| unified_exec.log_macos_seatbelt_denials)
.unwrap_or(false)
}
fn resolve_multi_agent_v2_config(config_toml: &ConfigToml) -> MultiAgentV2Config {
let base = multi_agent_v2_toml_config(config_toml.features.as_ref());
let max_concurrent_threads_per_session = base
@@ -3041,6 +3053,8 @@ impl Config {
let code_mode = resolve_code_mode_config(&cfg);
let multi_agent_v2 = resolve_multi_agent_v2_config(&cfg);
let terminal_resize_reflow = resolve_terminal_resize_reflow_config(&cfg);
let unified_exec_log_macos_seatbelt_denials =
resolve_unified_exec_log_macos_seatbelt_denials(&cfg);
let agent_roles =
agent_roles::load_agent_roles(fs, &cfg, &config_layer_stack, &mut startup_warnings)
@@ -3573,6 +3587,7 @@ impl Config {
experimental_request_user_input_enabled,
code_mode,
use_experimental_unified_exec_tool,
unified_exec_log_macos_seatbelt_denials,
background_terminal_max_timeout,
ghost_snapshot,
multi_agent_v2,
+51 -2
View File
@@ -43,6 +43,8 @@ use codex_sandboxing::SandboxTransformRequest;
use codex_sandboxing::SandboxType;
use codex_sandboxing::SandboxablePreference;
use codex_sandboxing::compatibility_sandbox_policy_for_permission_profile;
use codex_sandboxing::seatbelt_denials::DenialLogger;
use codex_sandboxing::seatbelt_denials::format_sandbox_denials;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
use codex_utils_pty::process_group::kill_child_process_group;
@@ -459,6 +461,7 @@ pub(crate) async fn execute_exec_request(
file_system_sandbox_policy: _,
network_sandbox_policy,
windows_sandbox_filesystem_overrides,
log_macos_seatbelt_denials,
arg0,
} = exec_request;
@@ -480,6 +483,7 @@ pub(crate) async fn execute_exec_request(
let raw_output_result = get_raw_output_result(
params,
network_sandbox_policy,
log_macos_seatbelt_denials,
stdout_stream,
after_spawn,
sandbox,
@@ -497,6 +501,7 @@ pub(crate) async fn execute_exec_request(
async fn get_raw_output_result(
params: ExecParams,
network_sandbox_policy: NetworkSandboxPolicy,
log_macos_seatbelt_denials: bool,
stdout_stream: Option<StdoutStream>,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
#[cfg_attr(not(windows), allow(unused_variables))] sandbox: SandboxType,
@@ -520,7 +525,15 @@ async fn get_raw_output_result(
.await;
}
exec(params, network_sandbox_policy, stdout_stream, after_spawn).await
exec(
params,
sandbox,
network_sandbox_policy,
log_macos_seatbelt_denials,
stdout_stream,
after_spawn,
)
.await
}
#[cfg(target_os = "windows")]
@@ -945,7 +958,9 @@ fn aggregate_output(
/// wrapper args, as appropriate.
async fn exec(
params: ExecParams,
sandbox: SandboxType,
network_sandbox_policy: NetworkSandboxPolicy,
log_macos_seatbelt_denials: bool,
stdout_stream: Option<StdoutStream>,
after_spawn: Option<Box<dyn FnOnce() + Send>>,
) -> Result<RawExecToolCallOutput> {
@@ -977,6 +992,11 @@ async fn exec(
))
})?;
let arg0_ref = arg0.as_deref();
let mut denial_logger = if log_macos_seatbelt_denials && sandbox == SandboxType::MacosSeatbelt {
DenialLogger::new_bounded().await
} else {
None
};
let child = spawn_child_async(SpawnChildRequest {
program: PathBuf::from(program),
args: args.into(),
@@ -991,10 +1011,39 @@ async fn exec(
env,
})
.await?;
if let Some(logger) = denial_logger.as_mut() {
logger.on_child_pid(child.id());
}
if let Some(after_spawn) = after_spawn {
after_spawn();
}
consume_output(child, expiration, capture_policy, stdout_stream).await
let mut raw_output = consume_output(child, expiration, capture_policy, stdout_stream).await?;
if let Some(logger) = denial_logger
&& let Some(bytes) = format_sandbox_denials(&logger.finish().await)
{
if let Some(max_bytes) = capture_policy.retained_bytes_cap() {
raw_output
.stderr
.text
.truncate(max_bytes.saturating_sub(bytes.len()));
}
raw_output.stderr.text.extend_from_slice(&bytes);
raw_output.aggregated_output = aggregate_output(
&raw_output.stdout,
&raw_output.stderr,
capture_policy.retained_bytes_cap(),
);
if let Some(max_bytes) = capture_policy.retained_bytes_cap()
&& !raw_output.aggregated_output.text.ends_with(&bytes)
{
raw_output
.aggregated_output
.text
.truncate(max_bytes.saturating_sub(bytes.len()));
raw_output.aggregated_output.text.extend_from_slice(&bytes);
}
}
Ok(raw_output)
}
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
+6
View File
@@ -279,7 +279,9 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> {
justification: None,
arg0: None,
},
SandboxType::None,
NetworkSandboxPolicy::Enabled,
/*log_macos_seatbelt_denials*/ false,
/*stdout_stream*/ None,
/*after_spawn*/ None,
)
@@ -315,7 +317,9 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p
justification: None,
arg0: None,
},
SandboxType::None,
NetworkSandboxPolicy::Enabled,
/*log_macos_seatbelt_denials*/ false,
/*stdout_stream*/ None,
/*after_spawn*/ None,
),
@@ -1061,7 +1065,9 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()>
let output = exec(
params,
SandboxType::None,
NetworkSandboxPolicy::Restricted,
/*log_macos_seatbelt_denials*/ false,
/*stdout_stream*/ None,
/*after_spawn*/ None,
)
+3
View File
@@ -57,6 +57,7 @@ pub struct ExecRequest {
pub file_system_sandbox_policy: FileSystemSandboxPolicy,
pub network_sandbox_policy: NetworkSandboxPolicy,
pub(crate) windows_sandbox_filesystem_overrides: Option<WindowsSandboxFilesystemOverrides>,
pub(crate) log_macos_seatbelt_denials: bool,
pub arg0: Option<String>,
}
@@ -96,6 +97,7 @@ impl ExecRequest {
file_system_sandbox_policy,
network_sandbox_policy,
windows_sandbox_filesystem_overrides: None,
log_macos_seatbelt_denials: false,
arg0,
}
}
@@ -150,6 +152,7 @@ impl ExecRequest {
file_system_sandbox_policy,
network_sandbox_policy,
windows_sandbox_filesystem_overrides: None,
log_macos_seatbelt_denials: false,
arg0,
}
}
+1
View File
@@ -194,6 +194,7 @@ pub(crate) async fn execute_user_shell_command(
file_system_sandbox_policy: permission_profile.file_system_sandbox_policy(),
network_sandbox_policy: permission_profile.network_sandbox_policy(),
windows_sandbox_filesystem_overrides: None,
log_macos_seatbelt_denials: false,
arg0: None,
};
@@ -161,6 +161,7 @@ pub(super) async fn try_run_zsh_fork(
file_system_sandbox_policy,
network_sandbox_policy,
windows_sandbox_filesystem_overrides: _windows_sandbox_filesystem_overrides,
log_macos_seatbelt_denials,
arg0,
} = sandbox_exec_request;
let ParsedShellCommand { script, login, .. } = extract_shell_script(&command)?;
@@ -186,6 +187,7 @@ pub(super) async fn try_run_zsh_fork(
windows_sandbox_workspace_roots,
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
use_legacy_landlock: ctx.turn.features.use_legacy_landlock(),
log_macos_seatbelt_denials,
};
let main_execve_wrapper_exe = ctx
.session
@@ -287,6 +289,7 @@ pub(crate) async fn prepare_unified_exec_zsh_fork(
windows_sandbox_workspace_roots: exec_request.windows_sandbox_workspace_roots.clone(),
codex_linux_sandbox_exe: ctx.turn.codex_linux_sandbox_exe.clone(),
use_legacy_landlock: ctx.turn.features.use_legacy_landlock(),
log_macos_seatbelt_denials: exec_request.log_macos_seatbelt_denials,
};
let escalation_policy = CoreShellActionProvider {
policy: Arc::clone(&exec_policy),
@@ -791,6 +794,7 @@ struct CoreShellCommandExecutor {
windows_sandbox_workspace_roots: Vec<AbsolutePathBuf>,
codex_linux_sandbox_exe: Option<PathBuf>,
use_legacy_landlock: bool,
log_macos_seatbelt_denials: bool,
}
struct PrepareSandboxedExecParams<'a> {
@@ -866,6 +870,7 @@ impl CoreShellCommandExecutor {
file_system_sandbox_policy: self.file_system_sandbox_policy.clone(),
network_sandbox_policy: self.network_sandbox_policy,
windows_sandbox_filesystem_overrides: None,
log_macos_seatbelt_denials: self.log_macos_seatbelt_denials,
arg0: self.arg0.clone(),
},
/*stdout_stream*/ None,
@@ -372,6 +372,7 @@ async fn unsandboxed_intercepted_exec_strips_managed_network_env() -> anyhow::Re
windows_sandbox_workspace_roots: vec![workdir.clone()],
codex_linux_sandbox_exe: None,
use_legacy_landlock: false,
log_macos_seatbelt_denials: false,
};
let mut env = HashMap::new();
env.insert(PROXY_ACTIVE_ENV_KEY.to_string(), "1".to_string());
@@ -331,6 +331,8 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
let mut exec_env = attempt
.env_for(command, options, managed_network)
.map_err(|err| ToolError::Codex(err.into()))?;
exec_env.log_macos_seatbelt_denials =
ctx.turn.config.unified_exec_log_macos_seatbelt_denials;
exec_env.exec_server_env_config = req.exec_server_env_config.clone();
match zsh_fork_backend::maybe_prepare_unified_exec(
req,
@@ -382,6 +384,8 @@ impl<'a> ToolRuntime<UnifiedExecRequest, UnifiedExecProcess> for UnifiedExecRunt
let mut exec_env = attempt
.env_for(command, options, managed_network)
.map_err(|err| ToolError::Codex(err.into()))?;
exec_env.log_macos_seatbelt_denials =
ctx.turn.config.unified_exec_log_macos_seatbelt_denials;
exec_env.exec_server_env_config = req.exec_server_env_config.clone();
self.manager
.open_session_with_exec_env(
+56 -35
View File
@@ -6,7 +6,6 @@ use std::sync::atomic::Ordering;
use tokio::sync::Mutex;
use tokio::sync::Notify;
use tokio::sync::broadcast;
use tokio::sync::oneshot::error::TryRecvError;
use tokio::sync::watch;
use tokio::task::JoinHandle;
use tokio::time::Duration;
@@ -22,6 +21,8 @@ use codex_protocol::exec_output::ExecToolCallOutput;
use codex_protocol::exec_output::StreamOutput;
use codex_protocol::protocol::TruncationPolicy;
use codex_sandboxing::SandboxType;
use codex_sandboxing::seatbelt_denials::DenialLogger;
use codex_sandboxing::seatbelt_denials::format_sandbox_denials;
use codex_utils_output_truncation::formatted_truncate_text;
use codex_utils_pty::ExecCommandSession;
use codex_utils_pty::ProcessSignal as PtyProcessSignal;
@@ -32,7 +33,8 @@ use super::UnifiedExecError;
use super::head_tail_buffer::HeadTailBuffer;
use super::process_state::ProcessState;
const EARLY_EXIT_GRACE_PERIOD: Duration = Duration::from_millis(150);
const EARLY_EXIT_GRACE_PERIOD: Duration = Duration::from_millis(250);
const LOCAL_OUTPUT_DRAIN_GRACE_PERIOD: Duration = Duration::from_millis(25);
pub(crate) trait SpawnLifecycle: std::fmt::Debug + Send + Sync {
/// Returns file descriptors that must stay open across the child `exec()`.
///
@@ -64,6 +66,26 @@ pub(crate) struct OutputHandles {
pub(crate) cancellation_token: CancellationToken,
}
async fn append_seatbelt_denials(
logger: Option<DenialLogger>,
buffer: &OutputBuffer,
output_notify: &Arc<Notify>,
output_tx: &broadcast::Sender<Vec<u8>>,
) {
let Some(logger) = logger else {
return;
};
let Some(bytes) = format_sandbox_denials(&logger.finish().await) else {
return;
};
let mut guard = buffer.lock().await;
guard.push_chunk(bytes.clone());
drop(guard);
let _ = output_tx.send(bytes);
output_notify.notify_waiters();
}
/// Transport-specific process handle used by unified exec.
enum ProcessHandle {
Local(Box<ExecCommandSession>),
@@ -316,59 +338,64 @@ impl UnifiedExecProcess {
spawned: SpawnedPty,
sandbox_type: SandboxType,
spawn_lifecycle: SpawnLifecycleHandle,
denial_logger: Option<DenialLogger>,
) -> Result<Self, UnifiedExecError> {
let SpawnedPty {
session: process_handle,
stdout_rx,
stderr_rx,
mut exit_rx,
exit_rx,
} = spawned;
let output_rx = codex_utils_pty::combine_output_receivers(stdout_rx, stderr_rx);
let mut managed = Self::new(
let managed = Self::new(
ProcessHandle::Local(Box::new(process_handle)),
sandbox_type,
Some(spawn_lifecycle),
);
managed.output_task = Some(Self::spawn_local_output_task(
let mut output_task = Self::spawn_local_output_task(
output_rx,
Arc::clone(&managed.output_buffer),
Arc::clone(&managed.output_notify),
Arc::clone(&managed.output_closed),
Arc::clone(&managed.output_closed_notify),
managed.output_tx.clone(),
));
match exit_rx.try_recv() {
Ok(exit_code) => {
managed.signal_exit(Some(exit_code));
managed.check_for_sandbox_denial().await?;
return Ok(managed);
}
Err(TryRecvError::Closed) => {
managed.signal_exit(/*exit_code*/ None);
managed.check_for_sandbox_denial().await?;
return Ok(managed);
}
Err(TryRecvError::Empty) => {}
}
if let Ok(exit_result) = tokio::time::timeout(EARLY_EXIT_GRACE_PERIOD, &mut exit_rx).await {
managed.signal_exit(exit_result.ok());
managed.check_for_sandbox_denial().await?;
return Ok(managed);
}
);
tokio::spawn({
let state_tx = managed.state_tx.clone();
let cancellation_token = managed.cancellation_token.clone();
let output_buffer = Arc::clone(&managed.output_buffer);
let output_notify = Arc::clone(&managed.output_notify);
let output_tx = managed.output_tx.clone();
let output_closed = Arc::clone(&managed.output_closed);
let output_closed_notify = Arc::clone(&managed.output_closed_notify);
async move {
let exit_code = exit_rx.await.ok();
let state = state_tx.borrow().clone();
let _ = state_tx.send_replace(state.exited(exit_code));
if tokio::time::timeout(LOCAL_OUTPUT_DRAIN_GRACE_PERIOD, &mut output_task)
.await
.is_err()
{
output_task.abort();
let _ = output_task.await;
}
append_seatbelt_denials(denial_logger, &output_buffer, &output_notify, &output_tx)
.await;
output_closed.store(true, Ordering::Release);
output_closed_notify.notify_waiters();
cancellation_token.cancel();
}
});
if tokio::time::timeout(
EARLY_EXIT_GRACE_PERIOD,
managed.cancellation_token.cancelled(),
)
.await
.is_ok()
{
managed.check_for_sandbox_denial().await?;
}
Ok(managed)
}
@@ -500,8 +527,6 @@ impl UnifiedExecProcess {
mut receiver: tokio::sync::broadcast::Receiver<Vec<u8>>,
buffer: OutputBuffer,
output_notify: Arc<Notify>,
output_closed: Arc<AtomicBool>,
output_closed_notify: Arc<Notify>,
output_tx: broadcast::Sender<Vec<u8>>,
) -> JoinHandle<()> {
tokio::spawn(async move {
@@ -515,11 +540,7 @@ impl UnifiedExecProcess {
output_notify.notify_waiters();
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
output_closed.store(true, Ordering::Release);
output_closed_notify.notify_waiters();
break;
}
Err(tokio::sync::broadcast::error::RecvError::Closed) => break,
};
}
})
@@ -54,6 +54,8 @@ use codex_protocol::config_types::ShellEnvironmentPolicy;
use codex_protocol::error::CodexErr;
use codex_protocol::error::SandboxErr;
use codex_protocol::protocol::ExecCommandSource;
use codex_sandboxing::SandboxType;
use codex_sandboxing::seatbelt_denials::DenialLogger;
use codex_tools::ToolName;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_output_truncation::approx_token_count;
@@ -970,6 +972,7 @@ impl UnifiedExecProcessManager {
spawned.map_err(|err| UnifiedExecError::create_process(err.to_string()))?,
request.sandbox,
spawn_lifecycle,
/*denial_logger*/ None,
)
.await;
}
@@ -989,6 +992,13 @@ impl UnifiedExecProcessManager {
return UnifiedExecProcess::from_exec_server_started(started, request.sandbox).await;
}
let mut denial_logger = if request.log_macos_seatbelt_denials
&& request.sandbox == SandboxType::MacosSeatbelt
{
DenialLogger::new_bounded().await
} else {
None
};
let (program, args) = request
.command
.split_first()
@@ -1017,8 +1027,12 @@ impl UnifiedExecProcessManager {
};
let spawned =
spawn_result.map_err(|err| UnifiedExecError::create_process(err.to_string()))?;
if let Some(logger) = denial_logger.as_mut() {
logger.on_child_pid(spawned.session.child_pid());
}
spawn_lifecycle.after_spawn();
UnifiedExecProcess::from_spawned(spawned, request.sandbox, spawn_lifecycle).await
UnifiedExecProcess::from_spawned(spawned, request.sandbox, spawn_lifecycle, denial_logger)
.await
}
pub(super) async fn open_session_with_sandbox(
@@ -108,6 +108,7 @@ fn exec_server_params_use_env_policy_overlay_contract() {
file_system_sandbox_policy,
network_sandbox_policy,
windows_sandbox_filesystem_overrides: None,
log_macos_seatbelt_denials: false,
arg0: None,
};
+132 -5
View File
@@ -2,6 +2,8 @@ use core_test_support::test_codex::local_selections;
use std::collections::HashMap;
use std::ffi::OsStr;
use std::fs;
#[cfg(target_os = "macos")]
use std::sync::Arc;
use std::sync::OnceLock;
use anyhow::Context;
@@ -45,6 +47,8 @@ use pretty_assertions::assert_eq;
use regex_lite::Regex;
use serde_json::Value;
use serde_json::json;
#[cfg(target_os = "macos")]
use tempfile::TempDir;
use tokio::time::Duration;
const UNIFIED_EXEC_LAGGED_OUTPUT_TIMEOUT: Duration = Duration::from_secs(30);
@@ -724,9 +728,10 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> {
let test = builder.build_with_remote_env(&server).await?;
let call_id = "uexec-full-lifecycle";
// This timing force the long-standing PTY
// The shell exits after entering the background watcher, but its descendant
// keeps the PTY output descriptors open.
let args = json!({
"cmd": "sleep 0.5; printf 'HELLO-FULL-LIFECYCLE'",
"cmd": "sleep 0.5; (sleep 5) & printf 'HELLO-FULL-LIFECYCLE'",
"yield_time_ms": 1000,
});
@@ -756,7 +761,10 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> {
let mut task_completed = false;
loop {
let msg = wait_for_event(&test.codex, |_| true).await;
let msg = tokio::time::timeout(Duration::from_secs(2), test.codex.next_event())
.await
.context("unified exec waited for a descendant holding the output descriptors")??
.msg;
match msg {
EventMsg::ExecCommandBegin(ev) if ev.call_id == call_id => begin_event = Some(ev),
EventMsg::ExecCommandEnd(ev) if ev.call_id == call_id => {
@@ -765,13 +773,13 @@ async fn unified_exec_full_lifecycle_with_background_end_event() -> Result<()> {
"expected a single ExecCommandEnd event for this call id"
);
end_event = Some(ev);
if task_completed && end_event.is_some() {
if task_completed {
break;
}
}
EventMsg::TurnComplete(_) => {
task_completed = true;
if task_completed && end_event.is_some() {
if end_event.is_some() {
break;
}
}
@@ -3286,6 +3294,125 @@ async fn unified_exec_python_prompt_under_seatbelt() -> Result<()> {
Ok(())
}
#[cfg(target_os = "macos")]
#[allow(clippy::expect_used)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_delivers_background_macos_seatbelt_denials_once() -> Result<()> {
skip_if_no_network!(Ok(()));
skip_if_sandbox!(Ok(()));
let server = start_mock_server().await;
let home = Arc::new(TempDir::new()?);
fs::write(
home.path().join("config.toml"),
"[tools.unified_exec]\nlog_macos_seatbelt_denials = true\n",
)?;
let mut builder = test_codex().with_home(home).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(&server).await?;
assert!(test.config.unified_exec_log_macos_seatbelt_denials);
let denied_path = test.workspace_path("uexec_background_denied_touch.txt");
let exec_call_id = "uexec-background-seatbelt-start";
let exec_args = serde_json::json!({
"cmd": format!("sleep 0.5; /usr/bin/touch {}", denied_path.display()),
"yield_time_ms": 50,
});
let poll_call_id = "uexec-background-seatbelt-poll";
let poll_args = serde_json::json!({
"chars": "",
"session_id": 1000,
"yield_time_ms": 1_500,
});
let responses = vec![
sse(vec![
ev_response_created("resp-1"),
ev_function_call(
exec_call_id,
"exec_command",
&serde_json::to_string(&exec_args)?,
),
ev_completed("resp-1"),
]),
sse(vec![
ev_response_created("resp-2"),
ev_function_call(
poll_call_id,
"write_stdin",
&serde_json::to_string(&poll_args)?,
),
ev_completed("resp-2"),
]),
sse(vec![
ev_response_created("resp-3"),
ev_assistant_message("msg-1", "done"),
ev_completed("resp-3"),
]),
];
let request_log = mount_sse_sequence(&server, responses).await;
submit_unified_exec_turn(
&test,
"write under read-only seatbelt after yielding",
PermissionProfile::read_only(),
)
.await?;
let (end_event, turn_completed) =
wait_for_unified_exec_end(&test, exec_call_id, &request_log).await;
if !turn_completed {
wait_for_event(&test.codex, |event| {
matches!(event, EventMsg::TurnComplete(_))
})
.await;
}
let bodies = request_log
.requests()
.into_iter()
.map(|request| request.body_json())
.collect::<Vec<_>>();
let outputs = collect_tool_outputs(&bodies)?;
let initial_output = outputs
.get(exec_call_id)
.context("missing initial exec_command output")?;
assert_eq!(initial_output.process_id.as_deref(), Some("1000"));
assert!(!initial_output.output.contains("=== Sandbox denials ==="));
let poll_output = outputs
.get(poll_call_id)
.context("missing write_stdin output")?;
assert_eq!(
poll_output
.output
.matches("=== Sandbox denials ===")
.count(),
1
);
assert_eq!(
end_event
.aggregated_output
.matches("=== Sandbox denials ===")
.count(),
1
);
let canonical_denied_path = denied_path
.parent()
.context("denied path parent")?
.canonicalize()?
.join(denied_path.file_name().context("denied path file name")?);
let expected_denial = format!("file-write-create {}", canonical_denied_path.display());
assert!(poll_output.output.contains(&expected_denial));
assert!(end_event.aggregated_output.contains(&expected_denial));
assert!(!denied_path.exists());
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn unified_exec_runs_on_all_platforms() -> Result<()> {
skip_if_no_network!(Ok(()));
@@ -272,6 +272,7 @@ fn new_config(model: Option<String>, arg0_paths: Arg0DispatchPaths) -> anyhow::R
experimental_request_user_input_enabled: true,
code_mode: Default::default(),
use_experimental_unified_exec_tool: false,
unified_exec_log_macos_seatbelt_denials: false,
background_terminal_max_timeout: 300_000,
ghost_snapshot: GhostSnapshotConfig::default(),
multi_agent_v2: MultiAgentV2Config::default(),
+1
View File
@@ -238,6 +238,7 @@ async fn spawn_process_with_stdin_mode(
});
let handle = ProcessHandle::new(
Some(pid),
writer_tx,
Box::new(PipeChildTerminator {
#[cfg(windows)]
+9
View File
@@ -109,6 +109,7 @@ type ResizeFn = Box<dyn FnMut(TerminalSize) -> anyhow::Result<()> + Send>;
/// Handle for driving an interactive process (PTY or pipe).
pub struct ProcessHandle {
child_pid: Option<u32>,
writer_tx: StdMutex<Option<mpsc::Sender<Vec<u8>>>>,
killer: StdMutex<Option<Box<dyn ChildTerminator>>>,
reader_handle: StdMutex<Option<JoinHandle<()>>>,
@@ -134,6 +135,7 @@ impl fmt::Debug for ProcessHandle {
impl ProcessHandle {
#[allow(clippy::too_many_arguments)]
pub(crate) fn new(
child_pid: Option<u32>,
writer_tx: mpsc::Sender<Vec<u8>>,
killer: Box<dyn ChildTerminator>,
reader_handle: JoinHandle<()>,
@@ -146,6 +148,7 @@ impl ProcessHandle {
resizer: Option<ResizeFn>,
) -> Self {
Self {
child_pid,
writer_tx: StdMutex::new(Some(writer_tx)),
killer: StdMutex::new(Some(killer)),
reader_handle: StdMutex::new(Some(reader_handle)),
@@ -159,6 +162,11 @@ impl ProcessHandle {
}
}
/// Returns the local child process ID when one is available.
pub fn child_pid(&self) -> Option<u32> {
self.child_pid
}
/// Returns a channel sender for writing raw bytes to the child stdin.
pub fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
if let Ok(writer_tx) = self.writer_tx.lock()
@@ -432,6 +440,7 @@ pub fn spawn_from_driver(driver: ProcessDriver) -> SpawnedProcess {
});
let handle = ProcessHandle::new(
/*child_pid*/ None,
writer_tx,
Box::new(ClosureTerminator { inner: terminator }),
reader_handle,
+4 -1
View File
@@ -183,11 +183,12 @@ async fn spawn_process_portable(
}
let mut child = pair.slave.spawn_command(command_builder)?;
let child_pid = child.process_id();
#[cfg(unix)]
// portable-pty establishes the spawned PTY child as a new session leader on
// Unix, so PID == PGID and we can reuse the pipe backend's process-group
// hard-kill semantics for descendants.
let process_group_id = child.process_id();
let process_group_id = child_pid;
let killer = child.clone_killer();
let (writer_tx, mut writer_rx) = mpsc::channel::<Vec<u8>>(128);
@@ -253,6 +254,7 @@ async fn spawn_process_portable(
};
let handle = ProcessHandle::new(
child_pid,
writer_tx,
Box::new(PtyChildTerminator {
killer,
@@ -411,6 +413,7 @@ async fn spawn_process_preserving_fds(
};
let handle = ProcessHandle::new(
Some(process_group_id),
writer_tx,
Box::new(RawPidTerminator { process_group_id }),
reader_handle,