mirror of
https://github.com/openai/codex.git
synced 2026-11-03 19:53:25 +00:00
feat(sandbox): append Seatbelt denials to unified exec
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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": [
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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))]
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -238,6 +238,7 @@ async fn spawn_process_with_stdin_mode(
|
||||
});
|
||||
|
||||
let handle = ProcessHandle::new(
|
||||
Some(pid),
|
||||
writer_tx,
|
||||
Box::new(PipeChildTerminator {
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user